trekoon 0.4.1 → 0.4.2

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.
Files changed (54) hide show
  1. package/.agents/skills/trekoon/SKILL.md +20 -577
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
  3. package/.agents/skills/trekoon/reference/execution.md +246 -7
  4. package/.agents/skills/trekoon/reference/planning.md +138 -1
  5. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  6. package/.agents/skills/trekoon/reference/sync.md +129 -0
  7. package/README.md +8 -1
  8. package/docs/ai-agents.md +17 -2
  9. package/docs/commands.md +147 -3
  10. package/docs/machine-contracts.md +123 -0
  11. package/docs/quickstart.md +52 -0
  12. package/package.json +1 -1
  13. package/src/board/assets/app.js +45 -13
  14. package/src/board/assets/components/Component.js +22 -8
  15. package/src/board/assets/components/Workspace.js +9 -3
  16. package/src/board/assets/components/helpers.js +4 -0
  17. package/src/board/assets/runtime/delegation.js +8 -0
  18. package/src/board/assets/runtime/focus-trap.js +48 -0
  19. package/src/board/assets/state/actions.js +42 -4
  20. package/src/board/assets/state/api.js +284 -11
  21. package/src/board/assets/state/store.js +79 -11
  22. package/src/board/assets/state/url.js +10 -0
  23. package/src/board/assets/state/utils.js +2 -1
  24. package/src/board/event-bus.ts +72 -0
  25. package/src/board/routes.ts +412 -33
  26. package/src/board/server.ts +77 -8
  27. package/src/board/wal-watcher.ts +302 -0
  28. package/src/commands/board.ts +52 -17
  29. package/src/commands/epic.ts +7 -9
  30. package/src/commands/error-utils.ts +54 -1
  31. package/src/commands/help.ts +69 -4
  32. package/src/commands/migrate.ts +153 -24
  33. package/src/commands/quickstart.ts +7 -0
  34. package/src/commands/subtask.ts +71 -10
  35. package/src/commands/suggest.ts +6 -13
  36. package/src/commands/task.ts +137 -88
  37. package/src/domain/batch-validation.ts +329 -0
  38. package/src/domain/cascade-planner.ts +412 -0
  39. package/src/domain/dependency-rules.ts +15 -0
  40. package/src/domain/mutation-service.ts +828 -192
  41. package/src/domain/search.ts +113 -0
  42. package/src/domain/tracker-domain.ts +150 -680
  43. package/src/domain/types.ts +53 -2
  44. package/src/index.ts +37 -0
  45. package/src/runtime/cli-shell.ts +44 -0
  46. package/src/runtime/daemon.ts +639 -0
  47. package/src/storage/backup.ts +166 -0
  48. package/src/storage/database.ts +261 -4
  49. package/src/storage/migrations.ts +422 -20
  50. package/src/storage/path.ts +8 -0
  51. package/src/storage/schema.ts +5 -1
  52. package/src/sync/event-writes.ts +38 -11
  53. package/src/sync/git-context.ts +226 -8
  54. package/src/sync/service.ts +650 -147
@@ -44,7 +44,7 @@ function isSyncFieldValueSupported(tableName: string, fieldName: string, value:
44
44
  return value === null && isSyncNullableStringField(tableName, fieldName);
45
45
  }
46
46
 
47
- function isCursorStale(db: Database, cursorToken: string, sourceBranch: string): boolean {
47
+ export function isCursorStale(db: Database, cursorToken: string, sourceBranch: string): boolean {
48
48
  if (cursorToken === "0:") {
49
49
  return false;
50
50
  }
@@ -57,10 +57,8 @@ function isCursorStale(db: Database, cursorToken: string, sourceBranch: string):
57
57
  return false;
58
58
  }
59
59
 
60
- // Check if the event referenced by the cursor still exists.
61
- // If the cursor references a specific event id, check for it.
62
- // Otherwise, check if any event at or after the cursor timestamp exists
63
- // on the source branch.
60
+ // Check if the event referenced by the cursor still exists in the live
61
+ // events table. If found, the cursor position is valid.
64
62
  if (id.length > 0) {
65
63
  const row = db
66
64
  .query("SELECT id FROM events WHERE id = ? LIMIT 1;")
@@ -70,20 +68,56 @@ function isCursorStale(db: Database, cursorToken: string, sourceBranch: string):
70
68
  }
71
69
  }
72
70
 
73
- // The referenced event is gone. Check if there are any events on the
74
- // source branch at or after the cursor timestamp if not, the cursor
75
- // may simply be at the end of the stream.
71
+ // Compute the earliest retained created_at on the cursor's source branch
72
+ // across both the live events table and event_archive (pruned events). If
73
+ // the cursor predates that minimum, history before the cursor on this
74
+ // branch has been lost and re-bootstrap is required.
75
+ //
76
+ // Scoping by git_branch is essential: a global MIN would falsely report
77
+ // staleness on branch B simply because branch A has older retained events
78
+ // than the cursor on B.
79
+ const minRow = db
80
+ .query(
81
+ `SELECT MIN(min_ts) AS min_ts FROM (
82
+ SELECT MIN(created_at) AS min_ts FROM events WHERE git_branch = ?
83
+ UNION ALL
84
+ SELECT MIN(created_at) AS min_ts FROM event_archive WHERE git_branch = ?
85
+ );`,
86
+ )
87
+ .get(sourceBranch, sourceBranch) as { min_ts: number | null } | null;
88
+
89
+ const minTs: number | null = minRow?.min_ts ?? null;
90
+
91
+ // If there are no events at all, the cursor may simply be ahead of an
92
+ // empty log — not stale.
93
+ if (minTs === null) {
94
+ return false;
95
+ }
96
+
97
+ // Cursor predates the earliest retained event: the history window has
98
+ // been pruned past the cursor's position.
99
+ if (createdAt < minTs) {
100
+ return true;
101
+ }
102
+
103
+ // The referenced event is gone but the cursor timestamp is within the
104
+ // retained window. Check if there are any events on the source branch at
105
+ // or after the cursor timestamp across both tables — if there are, events
106
+ // between the cursor and the oldest remaining event were pruned.
76
107
  const newerRow = db
77
108
  .query(
78
- `SELECT id FROM events
79
- WHERE git_branch = ? AND created_at >= ?
109
+ `SELECT id FROM (
110
+ SELECT id, created_at FROM events
111
+ WHERE git_branch = ? AND created_at >= ?
112
+ UNION ALL
113
+ SELECT id, created_at FROM event_archive
114
+ WHERE git_branch = ? AND created_at >= ?
115
+ )
80
116
  ORDER BY created_at ASC, id ASC
81
117
  LIMIT 1;`,
82
118
  )
83
- .get(sourceBranch, createdAt) as { id: string } | null;
119
+ .get(sourceBranch, createdAt, sourceBranch, createdAt) as { id: string } | null;
84
120
 
85
- // If there are newer events but our referenced event is gone,
86
- // events between the cursor and the oldest remaining event were pruned.
87
121
  return newerRow !== null;
88
122
  }
89
123
 
@@ -119,6 +153,24 @@ interface ConflictRow {
119
153
  readonly resolution: string;
120
154
  readonly created_at: number;
121
155
  readonly updated_at: number;
156
+ readonly worktree_path: string;
157
+ readonly current_branch: string;
158
+ }
159
+
160
+ /**
161
+ * Worktree+branch scope under which a conflict is recorded. Required so that
162
+ * cleanup, listing, and resolution paths can isolate conflicts that two
163
+ * sibling worktrees independently observed on the same entity. Without this
164
+ * scoping a `removeConflictsForEntityIds` from worktree A's pull would erase
165
+ * worktree B's pending conflicts on the same entity.
166
+ */
167
+ interface ConflictScope {
168
+ readonly worktreePath: string;
169
+ readonly currentBranch: string;
170
+ }
171
+
172
+ function scopeFromGitContext(git: { worktreePath: string; branchName: string | null }): ConflictScope {
173
+ return { worktreePath: git.worktreePath, currentBranch: git.branchName ?? "" };
122
174
  }
123
175
 
124
176
  interface ResolutionEventPayload {
@@ -127,6 +179,17 @@ interface ResolutionEventPayload {
127
179
  readonly field: string;
128
180
  readonly resolution: string;
129
181
  readonly value?: string | null;
182
+ /**
183
+ * Worktree+branch scope of the conflict row that was resolved on the
184
+ * emitter side. Receivers use these (combined with their OWN active
185
+ * scope) to ensure a single shared `source_event_id` does not cause one
186
+ * worktree's resolution to clobber a sibling worktree's pending row.
187
+ *
188
+ * Optional for back-compat with events emitted before scope-aware
189
+ * resolution was introduced.
190
+ */
191
+ readonly worktree_path?: string;
192
+ readonly current_branch?: string;
130
193
  }
131
194
 
132
195
  interface ResolutionWriteContext {
@@ -319,10 +382,18 @@ function saveCursor(
319
382
  });
320
383
  }
321
384
 
322
- function countPendingConflicts(db: Database): number {
385
+ function countPendingConflicts(db: Database, scope: ConflictScope): number {
323
386
  const row = db
324
- .query("SELECT COUNT(*) AS count FROM sync_conflicts WHERE resolution = 'pending';")
325
- .get() as { count: number } | null;
387
+ .query(
388
+ `
389
+ SELECT COUNT(*) AS count
390
+ FROM sync_conflicts
391
+ WHERE resolution = 'pending'
392
+ AND worktree_path = ?
393
+ AND current_branch = ?;
394
+ `,
395
+ )
396
+ .get(scope.worktreePath, scope.currentBranch) as { count: number } | null;
326
397
 
327
398
  return row?.count ?? 0;
328
399
  }
@@ -428,18 +499,197 @@ function dependencyEventIdentity(event: StoredEvent): DependencyEventIdentity |
428
499
  return dependencyEventIdentityFromFields(payloadValidation.fields);
429
500
  }
430
501
 
502
+ /**
503
+ * Memoized lookup table for "ours" field values keyed by
504
+ * `${entity_kind}|${entity_id}|${field_name}` on the current branch.
505
+ *
506
+ * Entries:
507
+ * - undefined: not yet probed
508
+ * - {found:false}: probed, no event on currentBranch touches this field
509
+ * - {found:true,value}: probed, most recent serialized local value found
510
+ */
511
+ type OursLookupResult =
512
+ | { readonly found: false }
513
+ | { readonly found: true; readonly value: string | null };
514
+
515
+ type OursValueCache = Map<string, OursLookupResult>;
516
+
517
+ function createOursValueCache(): OursValueCache {
518
+ return new Map<string, OursLookupResult>();
519
+ }
520
+
521
+ function oursCacheKey(entityKind: string, entityId: string, fieldName: string): string {
522
+ return `${entityKind}|${entityId}|${fieldName}`;
523
+ }
524
+
525
+ // Safe field-name guard for use in JSON1 path strings. We only inline
526
+ // fieldName into a `$.fields.<name>` path; any non-identifier character
527
+ // would either break the path syntax or invite injection-like surprises.
528
+ const SAFE_FIELD_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
529
+
530
+ function isSafeJsonPathField(fieldName: string): boolean {
531
+ return SAFE_FIELD_NAME_PATTERN.test(fieldName);
532
+ }
533
+
534
+ /**
535
+ * Fast O(1)-per-call probe (after first lookup is memoized) for the most
536
+ * recent local-branch event that touched a given (entity, field).
537
+ *
538
+ * Uses the `idx_events_entity_branch_cursor` index plus SQLite JSON1
539
+ * (`json_type`) to find the newest event whose payload has the field key,
540
+ * with a single LIMIT 1 query — replacing the previous unbounded batched
541
+ * history walk.
542
+ *
543
+ * Returns undefined when no event on `currentBranch` for `(entityKind,
544
+ * entityId)` has the field in its payload; otherwise the serialized
545
+ * local value (matching `serializeValue(payload.fields[field])`).
546
+ */
547
+ function lookupOursFieldValue(
548
+ localDb: Database,
549
+ cache: OursValueCache,
550
+ currentBranch: string,
551
+ entityKind: string,
552
+ entityId: string,
553
+ fieldName: string,
554
+ ): string | null | undefined {
555
+ const key = oursCacheKey(entityKind, entityId, fieldName);
556
+ const cached = cache.get(key);
557
+ if (cached !== undefined) {
558
+ return cached.found ? cached.value : undefined;
559
+ }
560
+
561
+ // json_type returns SQL NULL only when the JSON path does not exist.
562
+ // It returns the string 'null' when the field is explicitly null —
563
+ // we treat that as a real value (matching the legacy walk's behavior
564
+ // where only `typeof undefined` skipped a key).
565
+ //
566
+ // Defense-in-depth: pass fieldName as a SQLite bind value rather than
567
+ // interpolating it into the JS path string. The caller already gates
568
+ // fieldName via SAFE_FIELD_NAME_PATTERN, but binding ensures the value
569
+ // is never spliced into the path expression on the JS side. SQLite
570
+ // concatenates `'$.fields.' || ?` server-side; if the bound value were
571
+ // somehow malformed, the JSON-path parser rejects the whole expression
572
+ // (`bad JSON path`) instead of silently widening the query.
573
+ const row = localDb
574
+ .query(
575
+ `
576
+ SELECT json_extract(payload, '$.fields.' || ?) AS value,
577
+ json_type(payload, '$.fields.' || ?) AS jt
578
+ FROM events
579
+ WHERE entity_kind = ?
580
+ AND entity_id = ?
581
+ AND git_branch = ?
582
+ AND json_type(payload, '$.fields.' || ?) IS NOT NULL
583
+ ORDER BY created_at DESC, id DESC
584
+ LIMIT 1;
585
+ `,
586
+ )
587
+ .get(fieldName, fieldName, entityKind, entityId, currentBranch, fieldName) as
588
+ | { value: unknown; jt: string | null }
589
+ | null;
590
+
591
+ if (row === null || row.jt === null) {
592
+ cache.set(key, { found: false });
593
+ return undefined;
594
+ }
595
+
596
+ // Reconstruct serialized local value matching
597
+ // `serializeValue(JSON.parse(payload).fields[field])`.
598
+ // For JSON nulls, json_extract returns SQL NULL; we represent ours as "null".
599
+ // For other types we re-serialize using JSON.stringify on the JSON-extracted value.
600
+ const ours: string | null = row.jt === "null" ? "null" : JSON.stringify(row.value);
601
+
602
+ cache.set(key, { found: true, value: ours });
603
+ return ours;
604
+ }
605
+
431
606
  function entityFieldConflict(
432
607
  localDb: Database,
608
+ currentBranch: string | null,
433
609
  sourceBranch: string,
434
610
  event: StoredEvent,
435
611
  fieldName: string,
436
612
  incomingValue: unknown,
613
+ oursCache: OursValueCache,
437
614
  ): { oursValue: string | null; theirsValue: string | null } | null {
615
+ // Detached HEAD has no named branch — no local-branch events can conflict.
616
+ if (currentBranch === null) {
617
+ return null;
618
+ }
619
+
620
+ // Current-row short-circuit: live entity already matches the incoming
621
+ // value, so applying the incoming event is a no-op — no conflict possible.
622
+ //
623
+ // Only valid when the local row exists. If the row was deleted locally
624
+ // currentEntityFieldValue returns `undefined`, which serializeValue maps
625
+ // to `null` — that would falsely match an incoming `null` field and mask
626
+ // a real conflict (delete vs. concurrent update). When the row is gone
627
+ // we must fall through to the history walk so the local delete event is
628
+ // discovered and reported as a conflict against the incoming non-delete.
438
629
  const currentValue = currentEntityFieldValue(localDb, event.entity_kind, event.entity_id, fieldName);
439
- if (serializeValue(currentValue) === serializeValue(incomingValue)) {
630
+ const theirsValue = serializeValue(incomingValue);
631
+ if (typeof currentValue !== "undefined" && serializeValue(currentValue) === theirsValue) {
632
+ return null;
633
+ }
634
+
635
+ // Dependency events use identity-tuple matching (entity_id can be reused
636
+ // across distinct dependencies). Fall back to the legacy filtered scan;
637
+ // dependency events are bounded by per-entity history depth and not the
638
+ // hot path that the optimization targets. Same fallback applies for
639
+ // payload field names that can't safely be inlined into a JSON1 path
640
+ // (defense in depth — the canonical SYNC_ALLOWED_FIELDS are all simple
641
+ // identifiers, but incoming payloads are not strictly schema-checked).
642
+ const incomingDependencyIdentity = dependencyEventIdentity(event);
643
+ if (incomingDependencyIdentity !== null || !isSafeJsonPathField(fieldName)) {
644
+ return entityFieldConflictHistoryWalk(
645
+ localDb,
646
+ currentBranch,
647
+ event,
648
+ fieldName,
649
+ theirsValue,
650
+ incomingDependencyIdentity,
651
+ );
652
+ }
653
+
654
+ // Fast path: indexed + memoized probe for "most recent local event
655
+ // touching this field on this entity".
656
+ const oursValue = lookupOursFieldValue(
657
+ localDb,
658
+ oursCache,
659
+ currentBranch,
660
+ event.entity_kind,
661
+ event.entity_id,
662
+ fieldName,
663
+ );
664
+
665
+ if (oursValue === undefined) {
666
+ return null;
667
+ }
668
+
669
+ if (oursValue === theirsValue) {
440
670
  return null;
441
671
  }
442
672
 
673
+ return { oursValue, theirsValue };
674
+ }
675
+
676
+ /**
677
+ * Slow-path conflict detection. Preserves the legacy batched history walk
678
+ * for two cases:
679
+ * 1. Dependency events — identity (source/depends_on tuple) must match
680
+ * the incoming event because distinct dependencies can share an
681
+ * entity_id. A static-field probe is not sufficient.
682
+ * 2. Field names that cannot safely be inlined into a JSON1 path — falls
683
+ * back to JS-side payload parsing instead of a json_type SQL probe.
684
+ */
685
+ function entityFieldConflictHistoryWalk(
686
+ localDb: Database,
687
+ currentBranch: string,
688
+ event: StoredEvent,
689
+ fieldName: string,
690
+ theirsValue: string | null,
691
+ incomingDependencyIdentity: DependencyEventIdentity | null,
692
+ ): { oursValue: string | null; theirsValue: string | null } | null {
443
693
  let beforeCreatedAt = Number.MAX_SAFE_INTEGER;
444
694
  let beforeId = "\uffff";
445
695
 
@@ -451,7 +701,7 @@ function entityFieldConflict(
451
701
  FROM events
452
702
  WHERE entity_kind = ?
453
703
  AND entity_id = ?
454
- AND (git_branch IS NULL OR git_branch != ?)
704
+ AND git_branch = ?
455
705
  AND (
456
706
  created_at < ?
457
707
  OR (created_at = ? AND id < ?)
@@ -463,15 +713,13 @@ function entityFieldConflict(
463
713
  .all(
464
714
  event.entity_kind,
465
715
  event.entity_id,
466
- sourceBranch,
716
+ currentBranch,
467
717
  beforeCreatedAt,
468
718
  beforeCreatedAt,
469
719
  beforeId,
470
720
  CONFLICT_HISTORY_SCAN_BATCH_SIZE,
471
721
  ) as LocalEntityEventRow[];
472
722
 
473
- const incomingDependencyIdentity = dependencyEventIdentity(event);
474
-
475
723
  if (rows.length === 0) {
476
724
  return null;
477
725
  }
@@ -503,7 +751,6 @@ function entityFieldConflict(
503
751
  }
504
752
 
505
753
  const oursValue = serializeValue(localValue);
506
- const theirsValue = serializeValue(incomingValue);
507
754
 
508
755
  if (oursValue !== theirsValue) {
509
756
  return {
@@ -525,6 +772,7 @@ function createConflict(
525
772
  fieldName: string,
526
773
  oursValue: string | null,
527
774
  theirsValue: string | null,
775
+ scope: ConflictScope,
528
776
  resolution: string = "pending",
529
777
  ): void {
530
778
  const now: number = Date.now();
@@ -534,11 +782,19 @@ function createConflict(
534
782
  SELECT id, resolution, ours_value, theirs_value
535
783
  FROM sync_conflicts
536
784
  WHERE event_id = ? AND entity_kind = ? AND entity_id = ? AND field_name = ?
785
+ AND worktree_path = ? AND current_branch = ?
537
786
  ORDER BY CASE WHEN resolution = 'pending' THEN 0 ELSE 1 END, created_at ASC, id ASC
538
787
  LIMIT 1;
539
788
  `,
540
789
  )
541
- .get(event.id, event.entity_kind, event.entity_id, fieldName) as
790
+ .get(
791
+ event.id,
792
+ event.entity_kind,
793
+ event.entity_id,
794
+ fieldName,
795
+ scope.worktreePath,
796
+ scope.currentBranch,
797
+ ) as
542
798
  | { id: string; resolution: string; ours_value: string | null; theirs_value: string | null }
543
799
  | null;
544
800
 
@@ -580,32 +836,72 @@ function createConflict(
580
836
  resolution,
581
837
  created_at,
582
838
  updated_at,
583
- version
584
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1);
839
+ version,
840
+ worktree_path,
841
+ current_branch
842
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?);
585
843
  `,
586
- ).run(randomUUID(), event.id, event.entity_kind, event.entity_id, fieldName, oursValue, theirsValue, resolution, now, now);
844
+ ).run(
845
+ randomUUID(),
846
+ event.id,
847
+ event.entity_kind,
848
+ event.entity_id,
849
+ fieldName,
850
+ oursValue,
851
+ theirsValue,
852
+ resolution,
853
+ now,
854
+ now,
855
+ scope.worktreePath,
856
+ scope.currentBranch,
857
+ );
587
858
  }
588
859
 
589
860
  function findConflictForResolutionEvent(
590
861
  db: Database,
591
862
  event: StoredEvent,
592
863
  payload: ResolutionEventPayload,
864
+ receiverScope: ConflictScope,
593
865
  ): ConflictRow | null {
866
+ // Scope-isolated resolve: a single (event_id, entity, field) tuple can
867
+ // have multiple sync_conflicts rows — one per observer worktree+branch.
868
+ // The receiver only resolves its OWN scoped row. If the payload carries
869
+ // an emitter scope (new, scope-aware events), require it to match the
870
+ // receiver — cross-scope events are a no-op so a resolve in worktree A
871
+ // never mutates worktree B's row.
872
+ if (
873
+ typeof payload.worktree_path === "string" &&
874
+ typeof payload.current_branch === "string" &&
875
+ (payload.worktree_path !== receiverScope.worktreePath ||
876
+ payload.current_branch !== receiverScope.currentBranch)
877
+ ) {
878
+ return null;
879
+ }
880
+
594
881
  if (typeof payload.source_event_id === "string" && payload.source_event_id.length > 0) {
595
882
  const bySourceEvent = db
596
883
  .query(
597
884
  `
598
- SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
885
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
599
886
  FROM sync_conflicts
600
887
  WHERE event_id = ?
601
888
  AND entity_kind = ?
602
889
  AND entity_id = ?
603
890
  AND field_name = ?
891
+ AND worktree_path = ?
892
+ AND current_branch = ?
604
893
  ORDER BY CASE WHEN resolution = 'pending' THEN 0 ELSE 1 END, created_at ASC, id ASC
605
894
  LIMIT 1;
606
895
  `,
607
896
  )
608
- .get(payload.source_event_id, event.entity_kind, event.entity_id, payload.field) as ConflictRow | null;
897
+ .get(
898
+ payload.source_event_id,
899
+ event.entity_kind,
900
+ event.entity_id,
901
+ payload.field,
902
+ receiverScope.worktreePath,
903
+ receiverScope.currentBranch,
904
+ ) as ConflictRow | null;
609
905
 
610
906
  if (bySourceEvent) {
611
907
  return bySourceEvent;
@@ -619,23 +915,63 @@ function findConflictForResolutionEvent(
619
915
  return db
620
916
  .query(
621
917
  `
622
- SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
918
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
623
919
  FROM sync_conflicts
624
920
  WHERE id = ?
625
921
  AND entity_kind = ?
626
922
  AND entity_id = ?
627
923
  AND field_name = ?
924
+ AND worktree_path = ?
925
+ AND current_branch = ?
628
926
  LIMIT 1;
629
927
  `,
630
928
  )
631
- .get(payload.conflict_id, event.entity_kind, event.entity_id, payload.field) as ConflictRow | null;
929
+ .get(
930
+ payload.conflict_id,
931
+ event.entity_kind,
932
+ event.entity_id,
933
+ payload.field,
934
+ receiverScope.worktreePath,
935
+ receiverScope.currentBranch,
936
+ ) as ConflictRow | null;
632
937
  }
633
938
 
634
939
  function removeDependenciesTouchingNode(db: Database, nodeId: string): void {
635
940
  db.query("DELETE FROM dependencies WHERE source_id = ? OR depends_on_id = ?;").run(nodeId, nodeId);
636
941
  }
637
942
 
638
- function removeTaskSubtree(db: Database, taskId: string): void {
943
+ function removeConflictsForEntityIds(
944
+ db: Database,
945
+ entityKind: string,
946
+ entityIds: readonly string[],
947
+ scope: ConflictScope,
948
+ excludeConflictId?: string,
949
+ ): void {
950
+ if (entityIds.length === 0) {
951
+ return;
952
+ }
953
+ const placeholders = entityIds.map(() => "?").join(", ");
954
+ if (excludeConflictId !== undefined) {
955
+ db.query(
956
+ `DELETE FROM sync_conflicts
957
+ WHERE entity_kind = ?
958
+ AND entity_id IN (${placeholders})
959
+ AND worktree_path = ?
960
+ AND current_branch = ?
961
+ AND id != ?;`,
962
+ ).run(entityKind, ...entityIds, scope.worktreePath, scope.currentBranch, excludeConflictId);
963
+ } else {
964
+ db.query(
965
+ `DELETE FROM sync_conflicts
966
+ WHERE entity_kind = ?
967
+ AND entity_id IN (${placeholders})
968
+ AND worktree_path = ?
969
+ AND current_branch = ?;`,
970
+ ).run(entityKind, ...entityIds, scope.worktreePath, scope.currentBranch);
971
+ }
972
+ }
973
+
974
+ function removeTaskSubtree(db: Database, taskId: string): Array<{ id: string }> {
639
975
  const subtasks = db
640
976
  .query("SELECT id FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;")
641
977
  .all(taskId) as Array<{ id: string }>;
@@ -646,6 +982,7 @@ function removeTaskSubtree(db: Database, taskId: string): void {
646
982
 
647
983
  db.query("DELETE FROM subtasks WHERE task_id = ?;").run(taskId);
648
984
  removeDependenciesTouchingNode(db, taskId);
985
+ return subtasks;
649
986
  }
650
987
 
651
988
  function applyPendingDeleteCascadeResolution(db: Database, conflict: ConflictRow): void {
@@ -670,12 +1007,25 @@ function applyPendingDeleteCascadeResolution(db: Database, conflict: ConflictRow
670
1007
  }
671
1008
  }
672
1009
 
1010
+ function scopeFromConflictRow(conflict: ConflictRow): ConflictScope {
1011
+ return { worktreePath: conflict.worktree_path, currentBranch: conflict.current_branch };
1012
+ }
1013
+
673
1014
  function applyConflictTheirsResolution(db: Database, conflict: ConflictRow): void {
1015
+ // Cleanup is scoped to the conflict's own worktree+branch so resolving a
1016
+ // conflict in this worktree does not erase peer worktrees' pending
1017
+ // conflicts on the same entity.
1018
+ const scope: ConflictScope = scopeFromConflictRow(conflict);
1019
+
674
1020
  if (conflict.field_name === "__delete__") {
675
1021
  if (conflict.entity_kind === "task") {
676
- removeTaskSubtree(db, conflict.entity_id);
1022
+ const subtasks = removeTaskSubtree(db, conflict.entity_id);
1023
+ const subtaskIds = subtasks.map((s) => s.id);
1024
+ removeConflictsForEntityIds(db, "subtask", subtaskIds, scope, conflict.id);
1025
+ removeConflictsForEntityIds(db, "task", [conflict.entity_id], scope, conflict.id);
677
1026
  } else if (conflict.entity_kind === "subtask") {
678
1027
  removeDependenciesTouchingNode(db, conflict.entity_id);
1028
+ removeConflictsForEntityIds(db, "subtask", [conflict.entity_id], scope, conflict.id);
679
1029
  }
680
1030
  applyPendingDeleteCascadeResolution(db, conflict);
681
1031
  deleteSingleEntity(db, conflict.entity_kind, conflict.entity_id, { allowMissing: true });
@@ -687,7 +1037,11 @@ function applyConflictTheirsResolution(db: Database, conflict: ConflictRow): voi
687
1037
  });
688
1038
  }
689
1039
 
690
- function applyIncomingResolutionEvent(db: Database, event: StoredEvent): boolean {
1040
+ function applyIncomingResolutionEvent(
1041
+ db: Database,
1042
+ event: StoredEvent,
1043
+ receiverScope: ConflictScope,
1044
+ ): boolean {
691
1045
  const parsed = parseJsonObject(event.payload);
692
1046
  if (!parsed) {
693
1047
  return false;
@@ -704,7 +1058,7 @@ function applyIncomingResolutionEvent(db: Database, event: StoredEvent): boolean
704
1058
  return false;
705
1059
  }
706
1060
 
707
- const conflict = findConflictForResolutionEvent(db, event, resolutionPayload);
1061
+ const conflict = findConflictForResolutionEvent(db, event, resolutionPayload, receiverScope);
708
1062
  if (!conflict) {
709
1063
  return false;
710
1064
  }
@@ -739,16 +1093,16 @@ function applyIncomingResolutionEvent(db: Database, event: StoredEvent): boolean
739
1093
  return updated.changes > 0;
740
1094
  }
741
1095
 
742
- function hasLocalEntityEdits(db: Database, entityKind: string, entityId: string, sourceBranch: string): boolean {
1096
+ function hasLocalEntityEdits(db: Database, entityKind: string, entityId: string, currentBranch: string): boolean {
743
1097
  const row = db
744
1098
  .query(
745
- `SELECT 1 FROM events WHERE entity_kind = ? AND entity_id = ? AND (git_branch IS NULL OR git_branch != ?) LIMIT 1;`,
1099
+ `SELECT 1 FROM events WHERE entity_kind = ? AND entity_id = ? AND git_branch = ? LIMIT 1;`,
746
1100
  )
747
- .get(entityKind, entityId, sourceBranch);
1101
+ .get(entityKind, entityId, currentBranch);
748
1102
  return row !== null;
749
1103
  }
750
1104
 
751
- function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly string[], sourceBranch: string): boolean {
1105
+ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly string[], currentBranch: string): boolean {
752
1106
  if (nodeIds.length === 0) {
753
1107
  return false;
754
1108
  }
@@ -762,7 +1116,7 @@ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly st
762
1116
  SELECT 1
763
1117
  FROM events
764
1118
  WHERE entity_kind = 'dependency'
765
- AND (git_branch IS NULL OR git_branch != ?)
1119
+ AND git_branch = ?
766
1120
  AND (
767
1121
  json_extract(payload, '$.fields.source_id') IN (${placeholders})
768
1122
  OR json_extract(payload, '$.fields.depends_on_id') IN (${placeholders})
@@ -770,7 +1124,7 @@ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly st
770
1124
  LIMIT 1;
771
1125
  `,
772
1126
  )
773
- .get(sourceBranch, ...chunk, ...chunk);
1127
+ .get(currentBranch, ...chunk, ...chunk);
774
1128
 
775
1129
  if (row !== null) {
776
1130
  return true;
@@ -782,7 +1136,7 @@ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly st
782
1136
 
783
1137
  function hasLocalDependencyEditsForIdentity(
784
1138
  db: Database,
785
- sourceBranch: string,
1139
+ currentBranch: string,
786
1140
  identity: DependencyEventIdentity,
787
1141
  ): boolean {
788
1142
  const row = db
@@ -791,7 +1145,7 @@ function hasLocalDependencyEditsForIdentity(
791
1145
  SELECT 1
792
1146
  FROM events
793
1147
  WHERE entity_kind = 'dependency'
794
- AND (git_branch IS NULL OR git_branch != ?)
1148
+ AND git_branch = ?
795
1149
  AND json_extract(payload, '$.fields.source_id') = ?
796
1150
  AND json_extract(payload, '$.fields.source_kind') = ?
797
1151
  AND json_extract(payload, '$.fields.depends_on_id') = ?
@@ -799,7 +1153,7 @@ function hasLocalDependencyEditsForIdentity(
799
1153
  LIMIT 1;
800
1154
  `,
801
1155
  )
802
- .get(sourceBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind);
1156
+ .get(currentBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind);
803
1157
 
804
1158
  return row !== null;
805
1159
  }
@@ -824,7 +1178,7 @@ function dependencyRowExistsForIdentity(db: Database, identity: DependencyEventI
824
1178
 
825
1179
  function latestLocalDependencyOperationForIdentity(
826
1180
  db: Database,
827
- sourceBranch: string,
1181
+ currentBranch: string,
828
1182
  identity: DependencyEventIdentity,
829
1183
  ): string | null {
830
1184
  const row = db
@@ -833,7 +1187,7 @@ function latestLocalDependencyOperationForIdentity(
833
1187
  SELECT operation
834
1188
  FROM events
835
1189
  WHERE entity_kind = 'dependency'
836
- AND (git_branch IS NULL OR git_branch != ?)
1190
+ AND git_branch = ?
837
1191
  AND json_extract(payload, '$.fields.source_id') = ?
838
1192
  AND json_extract(payload, '$.fields.source_kind') = ?
839
1193
  AND json_extract(payload, '$.fields.depends_on_id') = ?
@@ -842,7 +1196,7 @@ function latestLocalDependencyOperationForIdentity(
842
1196
  LIMIT 1;
843
1197
  `,
844
1198
  )
845
- .get(sourceBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind) as
1199
+ .get(currentBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind) as
846
1200
  | { operation: string }
847
1201
  | null;
848
1202
 
@@ -851,7 +1205,7 @@ function latestLocalDependencyOperationForIdentity(
851
1205
 
852
1206
  function hasLocalDependencyRemovalForIdentity(
853
1207
  db: Database,
854
- sourceBranch: string,
1208
+ currentBranch: string,
855
1209
  identity: DependencyEventIdentity,
856
1210
  ): boolean {
857
1211
  const row = db
@@ -861,7 +1215,7 @@ function hasLocalDependencyRemovalForIdentity(
861
1215
  FROM events
862
1216
  WHERE entity_kind = 'dependency'
863
1217
  AND operation = 'dependency.removed'
864
- AND (git_branch IS NULL OR git_branch != ?)
1218
+ AND git_branch = ?
865
1219
  AND json_extract(payload, '$.fields.source_id') = ?
866
1220
  AND json_extract(payload, '$.fields.depends_on_id') = ?
867
1221
  AND (
@@ -875,12 +1229,17 @@ function hasLocalDependencyRemovalForIdentity(
875
1229
  LIMIT 1;
876
1230
  `,
877
1231
  )
878
- .get(sourceBranch, identity.sourceId, identity.dependsOnId, identity.sourceKind, identity.dependsOnKind);
1232
+ .get(currentBranch, identity.sourceId, identity.dependsOnId, identity.sourceKind, identity.dependsOnKind);
879
1233
 
880
1234
  return row !== null;
881
1235
  }
882
1236
 
883
- function hasLocalDependencyDeleteConflict(db: Database, event: StoredEvent, sourceBranch: string): boolean {
1237
+ function hasLocalDependencyDeleteConflict(db: Database, event: StoredEvent, currentBranch: string | null): boolean {
1238
+ // Detached HEAD has no named branch — no local-branch events can conflict.
1239
+ if (currentBranch === null) {
1240
+ return false;
1241
+ }
1242
+
884
1243
  const identity = dependencyEventIdentity(event);
885
1244
  if (identity === null) {
886
1245
  return false;
@@ -890,21 +1249,26 @@ function hasLocalDependencyDeleteConflict(db: Database, event: StoredEvent, sour
890
1249
  return false;
891
1250
  }
892
1251
 
893
- const latestOperation = latestLocalDependencyOperationForIdentity(db, sourceBranch, identity);
1252
+ const latestOperation = latestLocalDependencyOperationForIdentity(db, currentBranch, identity);
894
1253
  if (latestOperation === ENTITY_OPERATIONS.dependency.removed) {
895
1254
  return false;
896
1255
  }
897
1256
 
898
- return hasLocalDependencyEditsForIdentity(db, sourceBranch, identity);
1257
+ return hasLocalDependencyEditsForIdentity(db, currentBranch, identity);
899
1258
  }
900
1259
 
901
- function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent, sourceBranch: string): boolean {
902
- if (hasLocalEntityEdits(db, event.entity_kind, event.entity_id, sourceBranch)) {
1260
+ function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent, currentBranch: string | null): boolean {
1261
+ // Detached HEAD has no named branch — no local-branch events can conflict.
1262
+ if (currentBranch === null) {
1263
+ return false;
1264
+ }
1265
+
1266
+ if (hasLocalEntityEdits(db, event.entity_kind, event.entity_id, currentBranch)) {
903
1267
  return true;
904
1268
  }
905
1269
 
906
1270
  if (event.entity_kind === "subtask") {
907
- return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id], sourceBranch);
1271
+ return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id], currentBranch);
908
1272
  }
909
1273
 
910
1274
  if (event.entity_kind !== "task") {
@@ -917,12 +1281,12 @@ function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent, sourceBran
917
1281
  const subtaskIds = subtaskRows.map((row) => row.id);
918
1282
 
919
1283
  for (const subtaskId of subtaskIds) {
920
- if (hasLocalEntityEdits(db, "subtask", subtaskId, sourceBranch)) {
1284
+ if (hasLocalEntityEdits(db, "subtask", subtaskId, currentBranch)) {
921
1285
  return true;
922
1286
  }
923
1287
  }
924
1288
 
925
- return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id, ...subtaskIds], sourceBranch);
1289
+ return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id, ...subtaskIds], currentBranch);
926
1290
  }
927
1291
 
928
1292
  function rowExists(db: Database, tableName: string, id: string): boolean {
@@ -1122,7 +1486,12 @@ function applyUpdatePatch(db: Database, event: StoredEvent, fields: Record<strin
1122
1486
  return true;
1123
1487
  }
1124
1488
 
1125
- function applyDelete(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
1489
+ function applyDelete(
1490
+ db: Database,
1491
+ event: StoredEvent,
1492
+ fields: Record<string, unknown>,
1493
+ scope: ConflictScope,
1494
+ ): boolean {
1126
1495
  const tableName = tableForEntityKind(event.entity_kind);
1127
1496
  if (!tableName) {
1128
1497
  return false;
@@ -1140,16 +1509,22 @@ function applyDelete(db: Database, event: StoredEvent, fields: Record<string, un
1140
1509
  }
1141
1510
 
1142
1511
  if (event.entity_kind === "task") {
1143
- removeTaskSubtree(db, event.entity_id);
1512
+ const subtasks = removeTaskSubtree(db, event.entity_id);
1513
+ const subtaskIds = subtasks.map((s) => s.id);
1514
+ removeConflictsForEntityIds(db, "subtask", subtaskIds, scope);
1515
+ removeConflictsForEntityIds(db, "task", [event.entity_id], scope);
1144
1516
  } else if (event.entity_kind === "subtask") {
1145
1517
  removeDependenciesTouchingNode(db, event.entity_id);
1518
+ removeConflictsForEntityIds(db, "subtask", [event.entity_id], scope);
1519
+ } else {
1520
+ removeConflictsForEntityIds(db, event.entity_kind, [event.entity_id], scope);
1146
1521
  }
1147
1522
 
1148
1523
  db.query(`DELETE FROM ${tableName} WHERE id = ?;`).run(event.entity_id);
1149
1524
  return true;
1150
1525
  }
1151
1526
 
1152
- function hasPendingDeleteConflict(db: Database, sourceEventId: string): boolean {
1527
+ function hasPendingDeleteConflict(db: Database, sourceEventId: string, scope: ConflictScope): boolean {
1153
1528
  const row = db
1154
1529
  .query(
1155
1530
  `
@@ -1158,10 +1533,12 @@ function hasPendingDeleteConflict(db: Database, sourceEventId: string): boolean
1158
1533
  WHERE event_id = ?
1159
1534
  AND field_name = '__delete__'
1160
1535
  AND resolution = 'pending'
1536
+ AND worktree_path = ?
1537
+ AND current_branch = ?
1161
1538
  LIMIT 1;
1162
1539
  `,
1163
1540
  )
1164
- .get(sourceEventId);
1541
+ .get(sourceEventId, scope.worktreePath, scope.currentBranch);
1165
1542
 
1166
1543
  return row !== null;
1167
1544
  }
@@ -1171,23 +1548,36 @@ function pendingDeleteConflictSourceEventId(fields: Record<string, unknown>): st
1171
1548
  return typeof sourceEventId === "string" && sourceEventId.length > 0 ? sourceEventId : null;
1172
1549
  }
1173
1550
 
1174
- function shouldWithholdDeleteCascadeEvent(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
1551
+ function shouldWithholdDeleteCascadeEvent(
1552
+ db: Database,
1553
+ event: StoredEvent,
1554
+ fields: Record<string, unknown>,
1555
+ scope: ConflictScope,
1556
+ ): boolean {
1175
1557
  const sourceEventId = pendingDeleteConflictSourceEventId(fields);
1176
1558
  if (!sourceEventId) {
1177
1559
  return false;
1178
1560
  }
1179
1561
 
1180
- const isDeleteCascadeEvent = event.operation === "dependency.removed" || event.operation === "subtask.deleted";
1562
+ const isDeleteCascadeEvent =
1563
+ event.operation === "dependency.removed"
1564
+ || event.operation === "subtask.deleted"
1565
+ || event.operation === "task.deleted";
1181
1566
  if (!isDeleteCascadeEvent) {
1182
1567
  return false;
1183
1568
  }
1184
1569
 
1185
- return hasPendingDeleteConflict(db, sourceEventId);
1570
+ return hasPendingDeleteConflict(db, sourceEventId, scope);
1186
1571
  }
1187
1572
 
1188
- function applyEntityFields(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
1573
+ function applyEntityFields(
1574
+ db: Database,
1575
+ event: StoredEvent,
1576
+ fields: Record<string, unknown>,
1577
+ scope: ConflictScope,
1578
+ ): boolean {
1189
1579
  if (event.operation.endsWith(".deleted") || event.operation === "dependency.removed") {
1190
- return applyDelete(db, event, fields);
1580
+ return applyDelete(db, event, fields, scope);
1191
1581
  }
1192
1582
 
1193
1583
  if (event.operation.endsWith(".created") || event.operation === "dependency.added") {
@@ -1277,7 +1667,7 @@ export function syncStatus(cwd: string, sourceBranch: string): SyncStatusSummary
1277
1667
  sourceBranch,
1278
1668
  ahead: countAhead(storage.db, git.branchName, sourceBranch),
1279
1669
  behind: onSourceBranch ? 0 : countBranchEventsSince(storage.db, sourceBranch, cursorToken),
1280
- pendingConflicts: countPendingConflicts(storage.db),
1670
+ pendingConflicts: countPendingConflicts(storage.db, scopeFromGitContext(git)),
1281
1671
  sameBranch: onSourceBranch,
1282
1672
  git,
1283
1673
  };
@@ -1303,32 +1693,44 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
1303
1693
  let lastEventAt: number | null = cursor?.last_event_at ?? null;
1304
1694
  let scannedEvents = 0;
1305
1695
 
1306
- writeTransaction(storage.db, (): void => {
1307
- while (true) {
1308
- const incomingEvents = queryBranchEventsSinceBatch(
1309
- storage.db,
1310
- sourceBranch,
1311
- lastToken ?? cursorToken,
1312
- SYNC_PULL_BATCH_SIZE,
1313
- ) as StoredEvent[];
1314
-
1315
- if (incomingEvents.length === 0) {
1316
- break;
1317
- }
1696
+ // Chunked write transactions: each batch of SYNC_PULL_BATCH_SIZE
1697
+ // events is committed in its own transaction so the write lock is
1698
+ // never held across multiple batches. On crash, the cursor reflects
1699
+ // the last fully-committed batch and the next pull resumes there.
1700
+ while (true) {
1701
+ const incomingEvents = queryBranchEventsSinceBatch(
1702
+ storage.db,
1703
+ sourceBranch,
1704
+ lastToken ?? cursorToken,
1705
+ SYNC_PULL_BATCH_SIZE,
1706
+ ) as StoredEvent[];
1318
1707
 
1319
- scannedEvents += incomingEvents.length;
1708
+ if (incomingEvents.length === 0) {
1709
+ break;
1710
+ }
1320
1711
 
1712
+ const batchResult = writeTransaction(storage.db, (): { token: string | null; eventAt: number | null } => {
1713
+ let token: string | null = lastToken;
1714
+ let eventAt: number | null = lastEventAt;
1321
1715
  for (const incoming of incomingEvents) {
1322
1716
  storeEvent(storage.db, incoming);
1323
- lastToken = cursorTokenFromEvent(incoming);
1324
- lastEventAt = incoming.created_at;
1717
+ token = cursorTokenFromEvent(incoming);
1718
+ eventAt = incoming.created_at;
1325
1719
  }
1326
- }
1720
+ if (token) {
1721
+ saveCursor(storage.db, git.worktreePath, sourceBranch, token, eventAt);
1722
+ }
1723
+ return { token, eventAt };
1724
+ });
1725
+
1726
+ scannedEvents += incomingEvents.length;
1727
+ lastToken = batchResult.token;
1728
+ lastEventAt = batchResult.eventAt;
1327
1729
 
1328
- if (lastToken) {
1329
- saveCursor(storage.db, git.worktreePath, sourceBranch, lastToken, lastEventAt);
1730
+ if (incomingEvents.length < SYNC_PULL_BATCH_SIZE) {
1731
+ break;
1330
1732
  }
1331
- });
1733
+ }
1332
1734
 
1333
1735
  return {
1334
1736
  sourceBranch,
@@ -1360,71 +1762,106 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
1360
1762
  let lastEventAt: number | null = cursor?.last_event_at ?? null;
1361
1763
  let scannedEvents = 0;
1362
1764
 
1363
- writeTransaction(storage.db, (): void => {
1364
- while (true) {
1365
- const incomingEvents = queryBranchEventsSinceBatch(
1366
- storage.db,
1367
- sourceBranch,
1368
- lastToken ?? cursorToken,
1369
- SYNC_PULL_BATCH_SIZE,
1370
- ) as StoredEvent[];
1765
+ // Per-pull memoization for "ours" field-value lookups. Reused across
1766
+ // every incoming event so repeated probes of the same (entity, field)
1767
+ // are O(1) after first hit.
1768
+ const oursCache = createOursValueCache();
1769
+
1770
+ // Conflict scope: every conflict / cleanup created by this pull is
1771
+ // tagged with the current worktree+branch so peer worktrees observing
1772
+ // the same entity own their own row set and cannot erase each other.
1773
+ const conflictScope: ConflictScope = scopeFromGitContext(git);
1774
+
1775
+ // Chunked write transactions: each batch of SYNC_PULL_BATCH_SIZE
1776
+ // events is processed inside its own writeTransaction. The cursor and
1777
+ // lastEventAt are persisted at the end of each batch, so a crash mid-
1778
+ // pull leaves a consistent cursor pointing at the last fully-committed
1779
+ // batch and the next pull resumes from there. The write lock is no
1780
+ // longer held across multiple batches.
1781
+ while (true) {
1782
+ const incomingEvents = queryBranchEventsSinceBatch(
1783
+ storage.db,
1784
+ sourceBranch,
1785
+ lastToken ?? cursorToken,
1786
+ SYNC_PULL_BATCH_SIZE,
1787
+ ) as StoredEvent[];
1371
1788
 
1372
- if (incomingEvents.length === 0) {
1373
- break;
1374
- }
1789
+ if (incomingEvents.length === 0) {
1790
+ break;
1791
+ }
1375
1792
 
1376
- scannedEvents += incomingEvents.length;
1793
+ interface BatchResult {
1794
+ readonly appliedDelta: number;
1795
+ readonly createdConflictsDelta: number;
1796
+ readonly malformedPayloadDelta: number;
1797
+ readonly applyRejectedDelta: number;
1798
+ readonly quarantinedDelta: number;
1799
+ readonly conflictEventsDelta: number;
1800
+ readonly token: string | null;
1801
+ readonly eventAt: number | null;
1802
+ }
1803
+
1804
+ const batchResult: BatchResult = writeTransaction(storage.db, (): BatchResult => {
1805
+ let appliedDelta = 0;
1806
+ let createdConflictsDelta = 0;
1807
+ let malformedPayloadDelta = 0;
1808
+ let applyRejectedDelta = 0;
1809
+ let quarantinedDelta = 0;
1810
+ let conflictEventsDelta = 0;
1811
+ let token: string | null = lastToken;
1812
+ let eventAt: number | null = lastEventAt;
1377
1813
 
1378
1814
  for (const incoming of incomingEvents) {
1379
1815
  if (incoming.operation === "resolve_conflict") {
1380
- if (applyIncomingResolutionEvent(storage.db, incoming)) {
1381
- appliedEvents += 1;
1816
+ if (applyIncomingResolutionEvent(storage.db, incoming, conflictScope)) {
1817
+ appliedDelta += 1;
1382
1818
  }
1383
1819
  storeEvent(storage.db, incoming);
1384
- lastToken = cursorTokenFromEvent(incoming);
1385
- lastEventAt = incoming.created_at;
1820
+ token = cursorTokenFromEvent(incoming);
1821
+ eventAt = incoming.created_at;
1386
1822
  continue;
1387
1823
  }
1388
1824
 
1389
1825
  const payloadValidation = parsePayload(incoming.payload);
1390
1826
 
1391
1827
  if (!payloadValidation.ok) {
1392
- malformedPayloadEvents += 1;
1393
- quarantinedEvents += 1;
1828
+ malformedPayloadDelta += 1;
1829
+ quarantinedDelta += 1;
1394
1830
  createConflict(
1395
1831
  storage.db,
1396
1832
  incoming,
1397
1833
  "__payload__",
1398
1834
  null,
1399
1835
  payloadValidation.reason ?? "Invalid payload",
1836
+ conflictScope,
1400
1837
  "invalid",
1401
1838
  );
1402
- createdConflicts += 1;
1839
+ createdConflictsDelta += 1;
1403
1840
  storeEvent(storage.db, incoming);
1404
- lastToken = cursorTokenFromEvent(incoming);
1405
- lastEventAt = incoming.created_at;
1841
+ token = cursorTokenFromEvent(incoming);
1842
+ eventAt = incoming.created_at;
1406
1843
  continue;
1407
1844
  }
1408
1845
 
1409
1846
  const payload: EventPayload = { fields: payloadValidation.fields };
1410
1847
 
1411
- if (shouldWithholdDeleteCascadeEvent(storage.db, incoming, payload.fields)) {
1848
+ if (shouldWithholdDeleteCascadeEvent(storage.db, incoming, payload.fields, conflictScope)) {
1412
1849
  storeEvent(storage.db, incoming);
1413
- lastToken = cursorTokenFromEvent(incoming);
1414
- lastEventAt = incoming.created_at;
1850
+ token = cursorTokenFromEvent(incoming);
1851
+ eventAt = incoming.created_at;
1415
1852
  continue;
1416
1853
  }
1417
1854
 
1418
1855
  const isDeleteWithLocalEdits =
1419
- (incoming.operation.endsWith(".deleted") && hasLocalDeleteCascadeEdits(storage.db, incoming, sourceBranch)) ||
1420
- (incoming.operation === "dependency.removed" && hasLocalDependencyDeleteConflict(storage.db, incoming, sourceBranch));
1856
+ (incoming.operation.endsWith(".deleted") && hasLocalDeleteCascadeEdits(storage.db, incoming, git.branchName)) ||
1857
+ (incoming.operation === "dependency.removed" && hasLocalDependencyDeleteConflict(storage.db, incoming, git.branchName));
1421
1858
  if (isDeleteWithLocalEdits) {
1422
- createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch");
1423
- createdConflicts += 1;
1424
- conflictEvents += 1;
1859
+ createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch", conflictScope);
1860
+ createdConflictsDelta += 1;
1861
+ conflictEventsDelta += 1;
1425
1862
  storeEvent(storage.db, incoming);
1426
- lastToken = cursorTokenFromEvent(incoming);
1427
- lastEventAt = incoming.created_at;
1863
+ token = cursorTokenFromEvent(incoming);
1864
+ eventAt = incoming.created_at;
1428
1865
  continue;
1429
1866
  }
1430
1867
 
@@ -1437,47 +1874,88 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
1437
1874
  continue;
1438
1875
  }
1439
1876
 
1440
- const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
1877
+ const conflict = entityFieldConflict(
1878
+ storage.db,
1879
+ git.branchName,
1880
+ sourceBranch,
1881
+ incoming,
1882
+ fieldName,
1883
+ value,
1884
+ oursCache,
1885
+ );
1441
1886
 
1442
1887
  if (conflict) {
1443
1888
  withheldConflictCount += 1;
1444
- conflictEvents += 1;
1445
- createConflict(storage.db, incoming, fieldName, conflict.oursValue, conflict.theirsValue);
1446
- createdConflicts += 1;
1889
+ conflictEventsDelta += 1;
1890
+ createConflict(
1891
+ storage.db,
1892
+ incoming,
1893
+ fieldName,
1894
+ conflict.oursValue,
1895
+ conflict.theirsValue,
1896
+ conflictScope,
1897
+ );
1898
+ createdConflictsDelta += 1;
1447
1899
  continue;
1448
1900
  }
1449
1901
 
1450
1902
  fieldsToApply[fieldName] = value;
1451
1903
  }
1452
1904
 
1453
- if (applyEntityFields(storage.db, incoming, fieldsToApply)) {
1454
- appliedEvents += 1;
1905
+ if (applyEntityFields(storage.db, incoming, fieldsToApply, conflictScope)) {
1906
+ appliedDelta += 1;
1455
1907
  } else if (applyReplayedCreateWithConflicts(storage.db, incoming, fieldsToApply, withheldConflictCount)) {
1456
- appliedEvents += 1;
1908
+ appliedDelta += 1;
1457
1909
  } else {
1458
- applyRejectedEvents += 1;
1459
- quarantinedEvents += 1;
1910
+ applyRejectedDelta += 1;
1911
+ quarantinedDelta += 1;
1460
1912
  createConflict(
1461
1913
  storage.db,
1462
1914
  incoming,
1463
1915
  "__apply__",
1464
1916
  null,
1465
1917
  `Rejected event ${incoming.operation} for ${incoming.entity_kind}`,
1918
+ conflictScope,
1466
1919
  "invalid",
1467
1920
  );
1468
- createdConflicts += 1;
1921
+ createdConflictsDelta += 1;
1469
1922
  }
1470
1923
 
1471
1924
  storeEvent(storage.db, incoming);
1472
- lastToken = cursorTokenFromEvent(incoming);
1473
- lastEventAt = incoming.created_at;
1925
+ token = cursorTokenFromEvent(incoming);
1926
+ eventAt = incoming.created_at;
1927
+ }
1928
+
1929
+ if (token) {
1930
+ saveCursor(storage.db, git.worktreePath, sourceBranch, token, eventAt);
1474
1931
  }
1475
- }
1476
1932
 
1477
- if (lastToken) {
1478
- saveCursor(storage.db, git.worktreePath, sourceBranch, lastToken, lastEventAt);
1933
+ return {
1934
+ appliedDelta,
1935
+ createdConflictsDelta,
1936
+ malformedPayloadDelta,
1937
+ applyRejectedDelta,
1938
+ quarantinedDelta,
1939
+ conflictEventsDelta,
1940
+ token,
1941
+ eventAt,
1942
+ };
1943
+ });
1944
+
1945
+ scannedEvents += incomingEvents.length;
1946
+ appliedEvents += batchResult.appliedDelta;
1947
+ createdConflicts += batchResult.createdConflictsDelta;
1948
+ malformedPayloadEvents += batchResult.malformedPayloadDelta;
1949
+ applyRejectedEvents += batchResult.applyRejectedDelta;
1950
+ quarantinedEvents += batchResult.quarantinedDelta;
1951
+ conflictEvents += batchResult.conflictEventsDelta;
1952
+ lastToken = batchResult.token;
1953
+ lastEventAt = batchResult.eventAt;
1954
+
1955
+ if (incomingEvents.length < SYNC_PULL_BATCH_SIZE) {
1956
+ break;
1479
1957
  }
1480
- });
1958
+ }
1481
1959
 
1482
1960
  const errorHints: string[] = buildSyncErrorHints({
1483
1961
  malformedPayloadEvents,
@@ -1651,6 +2129,12 @@ function appendResolutionEvent(
1651
2129
  field: conflict.field_name,
1652
2130
  resolution,
1653
2131
  value: resolvedValue,
2132
+ // Embed the resolved row's scope so receivers can audit which
2133
+ // worktree/branch performed the resolution. Receivers still
2134
+ // intersect against their OWN active scope when looking up the
2135
+ // local row to mutate.
2136
+ worktree_path: conflict.worktree_path,
2137
+ current_branch: conflict.current_branch,
1654
2138
  }),
1655
2139
  gitBranch,
1656
2140
  gitHead,
@@ -1661,19 +2145,30 @@ function appendResolutionEvent(
1661
2145
 
1662
2146
  export function listSyncConflicts(cwd: string, mode: SyncConflictMode): SyncConflictListItem[] {
1663
2147
  const storage = openTrekoonDatabase(cwd);
2148
+ const git = resolveGitContext(cwd);
2149
+ const scope: ConflictScope = scopeFromGitContext(git);
1664
2150
 
1665
2151
  try {
1666
- const whereClause = mode === "pending" ? "WHERE resolution = 'pending'" : "";
2152
+ // Conflicts are scoped to the worktree+branch that recorded them. Each
2153
+ // worktree only sees its own pending/resolved conflicts so peer
2154
+ // worktrees on the same shared DB don't bleed into one another.
2155
+ const conditions: string[] = ["worktree_path = ?", "current_branch = ?"];
2156
+ const params: string[] = [scope.worktreePath, scope.currentBranch];
2157
+
2158
+ if (mode === "pending") {
2159
+ conditions.push("resolution = 'pending'");
2160
+ }
2161
+
1667
2162
  return storage.db
1668
2163
  .query(
1669
2164
  `
1670
- SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
2165
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
1671
2166
  FROM sync_conflicts
1672
- ${whereClause}
2167
+ WHERE ${conditions.join(" AND ")}
1673
2168
  ORDER BY created_at ASC;
1674
2169
  `,
1675
2170
  )
1676
- .all() as SyncConflictListItem[];
2171
+ .all(...params) as SyncConflictListItem[];
1677
2172
  } finally {
1678
2173
  storage.close();
1679
2174
  }
@@ -1686,7 +2181,7 @@ export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDe
1686
2181
  const conflict = storage.db
1687
2182
  .query(
1688
2183
  `
1689
- SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
2184
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
1690
2185
  FROM sync_conflicts
1691
2186
  WHERE id = ?
1692
2187
  LIMIT 1;
@@ -1740,7 +2235,7 @@ function lookupPendingConflict(db: Database, conflictId: string): ConflictRow {
1740
2235
  const conflict = db
1741
2236
  .query(
1742
2237
  `
1743
- SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
2238
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
1744
2239
  FROM sync_conflicts
1745
2240
  WHERE id = ?
1746
2241
  LIMIT 1;
@@ -1818,17 +2313,22 @@ export function syncResolvePreview(cwd: string, conflictId: string, resolution:
1818
2313
  function queryPendingConflictIds(
1819
2314
  db: Database,
1820
2315
  filters: ResolveAllQueryFilters,
2316
+ scope: ConflictScope,
1821
2317
  ): readonly string[] {
1822
- const conditions: string[] = ["resolution = 'pending'"];
1823
- const params: string[] = [];
2318
+ const conditions: string[] = [
2319
+ "c.resolution = 'pending'",
2320
+ "c.worktree_path = ?",
2321
+ "c.current_branch = ?",
2322
+ ];
2323
+ const params: string[] = [scope.worktreePath, scope.currentBranch];
1824
2324
 
1825
2325
  if (filters.entityId !== undefined) {
1826
- conditions.push("entity_id = ?");
2326
+ conditions.push("c.entity_id = ?");
1827
2327
  params.push(filters.entityId);
1828
2328
  }
1829
2329
 
1830
2330
  if (filters.fieldName !== undefined) {
1831
- conditions.push("field_name = ?");
2331
+ conditions.push("c.field_name = ?");
1832
2332
  params.push(filters.fieldName);
1833
2333
  }
1834
2334
 
@@ -1836,7 +2336,7 @@ function queryPendingConflictIds(
1836
2336
  SELECT c.id
1837
2337
  FROM sync_conflicts c
1838
2338
  LEFT JOIN events e ON e.id = c.event_id
1839
- WHERE ${conditions.map((condition) => condition.replaceAll("resolution", "c.resolution").replaceAll("entity_id", "c.entity_id").replaceAll("field_name", "c.field_name")).join(" AND ")}
2339
+ WHERE ${conditions.join(" AND ")}
1840
2340
  ORDER BY COALESCE(e.created_at, c.created_at) ASC, COALESCE(e.id, c.event_id) ASC, c.created_at ASC, c.id ASC;
1841
2341
  `;
1842
2342
 
@@ -1852,7 +2352,7 @@ function queryPendingConflictsByIds(db: Database, conflictIds: readonly string[]
1852
2352
  const rows = db
1853
2353
  .query(
1854
2354
  `
1855
- SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
2355
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
1856
2356
  FROM sync_conflicts
1857
2357
  WHERE resolution = 'pending' AND id IN (${placeholders});
1858
2358
  `,
@@ -1880,9 +2380,10 @@ export function syncResolveAll(
1880
2380
  try {
1881
2381
  persistGitContext(storage.db, git);
1882
2382
 
2383
+ const scope: ConflictScope = scopeFromGitContext(git);
1883
2384
  const resolvedIds: string[] = writeTransaction(storage.db, (): string[] => {
1884
2385
  const expectedConflictIds = options.expectedConflictIds;
1885
- const orderedConflictIds = expectedConflictIds ?? queryPendingConflictIds(storage.db, filters);
2386
+ const orderedConflictIds = expectedConflictIds ?? queryPendingConflictIds(storage.db, filters, scope);
1886
2387
 
1887
2388
  if (orderedConflictIds.length === 0) {
1888
2389
  throw new DomainError({
@@ -1936,10 +2437,12 @@ export function syncResolveAllPreview(
1936
2437
  filters: ResolveAllQueryFilters,
1937
2438
  ): ResolveAllPreviewSummary {
1938
2439
  const storage = openTrekoonDatabase(cwd);
2440
+ const git = resolveGitContext(cwd);
2441
+ const scope: ConflictScope = scopeFromGitContext(git);
1939
2442
  const normalizedFilters: ResolveAllFilters = normalizeResolveAllFilters(filters);
1940
2443
 
1941
2444
  try {
1942
- const conflictIds = queryPendingConflictIds(storage.db, filters);
2445
+ const conflictIds = queryPendingConflictIds(storage.db, filters, scope);
1943
2446
 
1944
2447
  if (conflictIds.length === 0) {
1945
2448
  throw new DomainError({