trekoon 0.4.1 → 0.4.3

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 (58) hide show
  1. package/.agents/skills/trekoon/SKILL.md +97 -765
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +91 -141
  3. package/.agents/skills/trekoon/reference/execution.md +188 -159
  4. package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
  5. package/.agents/skills/trekoon/reference/planning.md +213 -213
  6. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  7. package/.agents/skills/trekoon/reference/sync.md +82 -0
  8. package/README.md +29 -8
  9. package/docs/ai-agents.md +65 -6
  10. package/docs/commands.md +149 -5
  11. package/docs/machine-contracts.md +123 -0
  12. package/docs/quickstart.md +55 -3
  13. package/package.json +1 -1
  14. package/src/board/assets/app.js +47 -13
  15. package/src/board/assets/components/Component.js +20 -8
  16. package/src/board/assets/components/Workspace.js +9 -3
  17. package/src/board/assets/components/helpers.js +4 -0
  18. package/src/board/assets/runtime/delegation.js +8 -0
  19. package/src/board/assets/runtime/focus-trap.js +48 -0
  20. package/src/board/assets/state/actions.js +45 -4
  21. package/src/board/assets/state/api.js +304 -17
  22. package/src/board/assets/state/store.js +82 -11
  23. package/src/board/assets/state/url.js +10 -0
  24. package/src/board/assets/state/utils.js +2 -1
  25. package/src/board/event-bus.ts +81 -0
  26. package/src/board/routes.ts +430 -40
  27. package/src/board/server.ts +86 -10
  28. package/src/board/snapshot.ts +6 -0
  29. package/src/board/wal-watcher.ts +313 -0
  30. package/src/commands/board.ts +52 -17
  31. package/src/commands/epic.ts +7 -9
  32. package/src/commands/error-utils.ts +54 -1
  33. package/src/commands/help.ts +75 -10
  34. package/src/commands/migrate.ts +153 -24
  35. package/src/commands/quickstart.ts +7 -0
  36. package/src/commands/skills.ts +17 -5
  37. package/src/commands/subtask.ts +71 -10
  38. package/src/commands/suggest.ts +6 -13
  39. package/src/commands/task.ts +137 -88
  40. package/src/domain/batch-validation.ts +329 -0
  41. package/src/domain/cascade-planner.ts +412 -0
  42. package/src/domain/dependency-rules.ts +15 -0
  43. package/src/domain/mutation-service.ts +842 -187
  44. package/src/domain/search.ts +113 -0
  45. package/src/domain/tracker-domain.ts +167 -693
  46. package/src/domain/types.ts +56 -2
  47. package/src/export/render-markdown.ts +1 -2
  48. package/src/index.ts +37 -0
  49. package/src/runtime/cli-shell.ts +44 -0
  50. package/src/runtime/daemon.ts +700 -0
  51. package/src/storage/backup.ts +166 -0
  52. package/src/storage/database.ts +268 -4
  53. package/src/storage/migrations.ts +441 -22
  54. package/src/storage/path.ts +8 -0
  55. package/src/storage/schema.ts +5 -1
  56. package/src/sync/event-writes.ts +38 -11
  57. package/src/sync/git-context.ts +226 -8
  58. package/src/sync/service.ts +679 -156
@@ -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,67 @@ 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
+
954
+ for (let offset = 0; offset < entityIds.length; offset += RESOLVE_ALL_CHUNK_SIZE) {
955
+ const chunkIds = entityIds.slice(offset, offset + RESOLVE_ALL_CHUNK_SIZE);
956
+ const placeholders = chunkIds.map(() => "?").join(", ");
957
+ if (excludeConflictId !== undefined) {
958
+ db.query(
959
+ `DELETE FROM sync_conflicts
960
+ WHERE entity_kind = ?
961
+ AND entity_id IN (${placeholders})
962
+ AND worktree_path = ?
963
+ AND current_branch = ?
964
+ AND id != ?;`,
965
+ ).run(entityKind, ...chunkIds, scope.worktreePath, scope.currentBranch, excludeConflictId);
966
+ } else {
967
+ db.query(
968
+ `DELETE FROM sync_conflicts
969
+ WHERE entity_kind = ?
970
+ AND entity_id IN (${placeholders})
971
+ AND worktree_path = ?
972
+ AND current_branch = ?;`,
973
+ ).run(entityKind, ...chunkIds, scope.worktreePath, scope.currentBranch);
974
+ }
975
+ }
976
+ }
977
+
978
+ function removeTaskSubtree(db: Database, taskId: string): Array<{ id: string }> {
639
979
  const subtasks = db
640
980
  .query("SELECT id FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;")
641
981
  .all(taskId) as Array<{ id: string }>;
@@ -646,6 +986,7 @@ function removeTaskSubtree(db: Database, taskId: string): void {
646
986
 
647
987
  db.query("DELETE FROM subtasks WHERE task_id = ?;").run(taskId);
648
988
  removeDependenciesTouchingNode(db, taskId);
989
+ return subtasks;
649
990
  }
650
991
 
651
992
  function applyPendingDeleteCascadeResolution(db: Database, conflict: ConflictRow): void {
@@ -670,12 +1011,25 @@ function applyPendingDeleteCascadeResolution(db: Database, conflict: ConflictRow
670
1011
  }
671
1012
  }
672
1013
 
1014
+ function scopeFromConflictRow(conflict: ConflictRow): ConflictScope {
1015
+ return { worktreePath: conflict.worktree_path, currentBranch: conflict.current_branch };
1016
+ }
1017
+
673
1018
  function applyConflictTheirsResolution(db: Database, conflict: ConflictRow): void {
1019
+ // Cleanup is scoped to the conflict's own worktree+branch so resolving a
1020
+ // conflict in this worktree does not erase peer worktrees' pending
1021
+ // conflicts on the same entity.
1022
+ const scope: ConflictScope = scopeFromConflictRow(conflict);
1023
+
674
1024
  if (conflict.field_name === "__delete__") {
675
1025
  if (conflict.entity_kind === "task") {
676
- removeTaskSubtree(db, conflict.entity_id);
1026
+ const subtasks = removeTaskSubtree(db, conflict.entity_id);
1027
+ const subtaskIds = subtasks.map((s) => s.id);
1028
+ removeConflictsForEntityIds(db, "subtask", subtaskIds, scope, conflict.id);
1029
+ removeConflictsForEntityIds(db, "task", [conflict.entity_id], scope, conflict.id);
677
1030
  } else if (conflict.entity_kind === "subtask") {
678
1031
  removeDependenciesTouchingNode(db, conflict.entity_id);
1032
+ removeConflictsForEntityIds(db, "subtask", [conflict.entity_id], scope, conflict.id);
679
1033
  }
680
1034
  applyPendingDeleteCascadeResolution(db, conflict);
681
1035
  deleteSingleEntity(db, conflict.entity_kind, conflict.entity_id, { allowMissing: true });
@@ -687,7 +1041,11 @@ function applyConflictTheirsResolution(db: Database, conflict: ConflictRow): voi
687
1041
  });
688
1042
  }
689
1043
 
690
- function applyIncomingResolutionEvent(db: Database, event: StoredEvent): boolean {
1044
+ function applyIncomingResolutionEvent(
1045
+ db: Database,
1046
+ event: StoredEvent,
1047
+ receiverScope: ConflictScope,
1048
+ ): boolean {
691
1049
  const parsed = parseJsonObject(event.payload);
692
1050
  if (!parsed) {
693
1051
  return false;
@@ -704,7 +1062,7 @@ function applyIncomingResolutionEvent(db: Database, event: StoredEvent): boolean
704
1062
  return false;
705
1063
  }
706
1064
 
707
- const conflict = findConflictForResolutionEvent(db, event, resolutionPayload);
1065
+ const conflict = findConflictForResolutionEvent(db, event, resolutionPayload, receiverScope);
708
1066
  if (!conflict) {
709
1067
  return false;
710
1068
  }
@@ -739,16 +1097,16 @@ function applyIncomingResolutionEvent(db: Database, event: StoredEvent): boolean
739
1097
  return updated.changes > 0;
740
1098
  }
741
1099
 
742
- function hasLocalEntityEdits(db: Database, entityKind: string, entityId: string, sourceBranch: string): boolean {
1100
+ function hasLocalEntityEdits(db: Database, entityKind: string, entityId: string, currentBranch: string): boolean {
743
1101
  const row = db
744
1102
  .query(
745
- `SELECT 1 FROM events WHERE entity_kind = ? AND entity_id = ? AND (git_branch IS NULL OR git_branch != ?) LIMIT 1;`,
1103
+ `SELECT 1 FROM events WHERE entity_kind = ? AND entity_id = ? AND git_branch = ? LIMIT 1;`,
746
1104
  )
747
- .get(entityKind, entityId, sourceBranch);
1105
+ .get(entityKind, entityId, currentBranch);
748
1106
  return row !== null;
749
1107
  }
750
1108
 
751
- function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly string[], sourceBranch: string): boolean {
1109
+ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly string[], currentBranch: string): boolean {
752
1110
  if (nodeIds.length === 0) {
753
1111
  return false;
754
1112
  }
@@ -762,7 +1120,7 @@ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly st
762
1120
  SELECT 1
763
1121
  FROM events
764
1122
  WHERE entity_kind = 'dependency'
765
- AND (git_branch IS NULL OR git_branch != ?)
1123
+ AND git_branch = ?
766
1124
  AND (
767
1125
  json_extract(payload, '$.fields.source_id') IN (${placeholders})
768
1126
  OR json_extract(payload, '$.fields.depends_on_id') IN (${placeholders})
@@ -770,7 +1128,7 @@ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly st
770
1128
  LIMIT 1;
771
1129
  `,
772
1130
  )
773
- .get(sourceBranch, ...chunk, ...chunk);
1131
+ .get(currentBranch, ...chunk, ...chunk);
774
1132
 
775
1133
  if (row !== null) {
776
1134
  return true;
@@ -782,7 +1140,7 @@ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly st
782
1140
 
783
1141
  function hasLocalDependencyEditsForIdentity(
784
1142
  db: Database,
785
- sourceBranch: string,
1143
+ currentBranch: string,
786
1144
  identity: DependencyEventIdentity,
787
1145
  ): boolean {
788
1146
  const row = db
@@ -791,7 +1149,7 @@ function hasLocalDependencyEditsForIdentity(
791
1149
  SELECT 1
792
1150
  FROM events
793
1151
  WHERE entity_kind = 'dependency'
794
- AND (git_branch IS NULL OR git_branch != ?)
1152
+ AND git_branch = ?
795
1153
  AND json_extract(payload, '$.fields.source_id') = ?
796
1154
  AND json_extract(payload, '$.fields.source_kind') = ?
797
1155
  AND json_extract(payload, '$.fields.depends_on_id') = ?
@@ -799,7 +1157,7 @@ function hasLocalDependencyEditsForIdentity(
799
1157
  LIMIT 1;
800
1158
  `,
801
1159
  )
802
- .get(sourceBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind);
1160
+ .get(currentBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind);
803
1161
 
804
1162
  return row !== null;
805
1163
  }
@@ -824,7 +1182,7 @@ function dependencyRowExistsForIdentity(db: Database, identity: DependencyEventI
824
1182
 
825
1183
  function latestLocalDependencyOperationForIdentity(
826
1184
  db: Database,
827
- sourceBranch: string,
1185
+ currentBranch: string,
828
1186
  identity: DependencyEventIdentity,
829
1187
  ): string | null {
830
1188
  const row = db
@@ -833,7 +1191,7 @@ function latestLocalDependencyOperationForIdentity(
833
1191
  SELECT operation
834
1192
  FROM events
835
1193
  WHERE entity_kind = 'dependency'
836
- AND (git_branch IS NULL OR git_branch != ?)
1194
+ AND git_branch = ?
837
1195
  AND json_extract(payload, '$.fields.source_id') = ?
838
1196
  AND json_extract(payload, '$.fields.source_kind') = ?
839
1197
  AND json_extract(payload, '$.fields.depends_on_id') = ?
@@ -842,7 +1200,7 @@ function latestLocalDependencyOperationForIdentity(
842
1200
  LIMIT 1;
843
1201
  `,
844
1202
  )
845
- .get(sourceBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind) as
1203
+ .get(currentBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind) as
846
1204
  | { operation: string }
847
1205
  | null;
848
1206
 
@@ -851,7 +1209,7 @@ function latestLocalDependencyOperationForIdentity(
851
1209
 
852
1210
  function hasLocalDependencyRemovalForIdentity(
853
1211
  db: Database,
854
- sourceBranch: string,
1212
+ currentBranch: string,
855
1213
  identity: DependencyEventIdentity,
856
1214
  ): boolean {
857
1215
  const row = db
@@ -861,7 +1219,7 @@ function hasLocalDependencyRemovalForIdentity(
861
1219
  FROM events
862
1220
  WHERE entity_kind = 'dependency'
863
1221
  AND operation = 'dependency.removed'
864
- AND (git_branch IS NULL OR git_branch != ?)
1222
+ AND git_branch = ?
865
1223
  AND json_extract(payload, '$.fields.source_id') = ?
866
1224
  AND json_extract(payload, '$.fields.depends_on_id') = ?
867
1225
  AND (
@@ -875,12 +1233,17 @@ function hasLocalDependencyRemovalForIdentity(
875
1233
  LIMIT 1;
876
1234
  `,
877
1235
  )
878
- .get(sourceBranch, identity.sourceId, identity.dependsOnId, identity.sourceKind, identity.dependsOnKind);
1236
+ .get(currentBranch, identity.sourceId, identity.dependsOnId, identity.sourceKind, identity.dependsOnKind);
879
1237
 
880
1238
  return row !== null;
881
1239
  }
882
1240
 
883
- function hasLocalDependencyDeleteConflict(db: Database, event: StoredEvent, sourceBranch: string): boolean {
1241
+ function hasLocalDependencyDeleteConflict(db: Database, event: StoredEvent, currentBranch: string | null): boolean {
1242
+ // Detached HEAD has no named branch — no local-branch events can conflict.
1243
+ if (currentBranch === null) {
1244
+ return false;
1245
+ }
1246
+
884
1247
  const identity = dependencyEventIdentity(event);
885
1248
  if (identity === null) {
886
1249
  return false;
@@ -890,21 +1253,26 @@ function hasLocalDependencyDeleteConflict(db: Database, event: StoredEvent, sour
890
1253
  return false;
891
1254
  }
892
1255
 
893
- const latestOperation = latestLocalDependencyOperationForIdentity(db, sourceBranch, identity);
1256
+ const latestOperation = latestLocalDependencyOperationForIdentity(db, currentBranch, identity);
894
1257
  if (latestOperation === ENTITY_OPERATIONS.dependency.removed) {
895
1258
  return false;
896
1259
  }
897
1260
 
898
- return hasLocalDependencyEditsForIdentity(db, sourceBranch, identity);
1261
+ return hasLocalDependencyEditsForIdentity(db, currentBranch, identity);
899
1262
  }
900
1263
 
901
- function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent, sourceBranch: string): boolean {
902
- if (hasLocalEntityEdits(db, event.entity_kind, event.entity_id, sourceBranch)) {
1264
+ function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent, currentBranch: string | null): boolean {
1265
+ // Detached HEAD has no named branch — no local-branch events can conflict.
1266
+ if (currentBranch === null) {
1267
+ return false;
1268
+ }
1269
+
1270
+ if (hasLocalEntityEdits(db, event.entity_kind, event.entity_id, currentBranch)) {
903
1271
  return true;
904
1272
  }
905
1273
 
906
1274
  if (event.entity_kind === "subtask") {
907
- return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id], sourceBranch);
1275
+ return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id], currentBranch);
908
1276
  }
909
1277
 
910
1278
  if (event.entity_kind !== "task") {
@@ -917,12 +1285,12 @@ function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent, sourceBran
917
1285
  const subtaskIds = subtaskRows.map((row) => row.id);
918
1286
 
919
1287
  for (const subtaskId of subtaskIds) {
920
- if (hasLocalEntityEdits(db, "subtask", subtaskId, sourceBranch)) {
1288
+ if (hasLocalEntityEdits(db, "subtask", subtaskId, currentBranch)) {
921
1289
  return true;
922
1290
  }
923
1291
  }
924
1292
 
925
- return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id, ...subtaskIds], sourceBranch);
1293
+ return hasLocalDependencyEditsTouchingNodes(db, [event.entity_id, ...subtaskIds], currentBranch);
926
1294
  }
927
1295
 
928
1296
  function rowExists(db: Database, tableName: string, id: string): boolean {
@@ -1122,7 +1490,12 @@ function applyUpdatePatch(db: Database, event: StoredEvent, fields: Record<strin
1122
1490
  return true;
1123
1491
  }
1124
1492
 
1125
- function applyDelete(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
1493
+ function applyDelete(
1494
+ db: Database,
1495
+ event: StoredEvent,
1496
+ fields: Record<string, unknown>,
1497
+ scope: ConflictScope,
1498
+ ): boolean {
1126
1499
  const tableName = tableForEntityKind(event.entity_kind);
1127
1500
  if (!tableName) {
1128
1501
  return false;
@@ -1140,16 +1513,22 @@ function applyDelete(db: Database, event: StoredEvent, fields: Record<string, un
1140
1513
  }
1141
1514
 
1142
1515
  if (event.entity_kind === "task") {
1143
- removeTaskSubtree(db, event.entity_id);
1516
+ const subtasks = removeTaskSubtree(db, event.entity_id);
1517
+ const subtaskIds = subtasks.map((s) => s.id);
1518
+ removeConflictsForEntityIds(db, "subtask", subtaskIds, scope);
1519
+ removeConflictsForEntityIds(db, "task", [event.entity_id], scope);
1144
1520
  } else if (event.entity_kind === "subtask") {
1145
1521
  removeDependenciesTouchingNode(db, event.entity_id);
1522
+ removeConflictsForEntityIds(db, "subtask", [event.entity_id], scope);
1523
+ } else {
1524
+ removeConflictsForEntityIds(db, event.entity_kind, [event.entity_id], scope);
1146
1525
  }
1147
1526
 
1148
1527
  db.query(`DELETE FROM ${tableName} WHERE id = ?;`).run(event.entity_id);
1149
1528
  return true;
1150
1529
  }
1151
1530
 
1152
- function hasPendingDeleteConflict(db: Database, sourceEventId: string): boolean {
1531
+ function hasPendingDeleteConflict(db: Database, sourceEventId: string, scope: ConflictScope): boolean {
1153
1532
  const row = db
1154
1533
  .query(
1155
1534
  `
@@ -1158,10 +1537,12 @@ function hasPendingDeleteConflict(db: Database, sourceEventId: string): boolean
1158
1537
  WHERE event_id = ?
1159
1538
  AND field_name = '__delete__'
1160
1539
  AND resolution = 'pending'
1540
+ AND worktree_path = ?
1541
+ AND current_branch = ?
1161
1542
  LIMIT 1;
1162
1543
  `,
1163
1544
  )
1164
- .get(sourceEventId);
1545
+ .get(sourceEventId, scope.worktreePath, scope.currentBranch);
1165
1546
 
1166
1547
  return row !== null;
1167
1548
  }
@@ -1171,23 +1552,36 @@ function pendingDeleteConflictSourceEventId(fields: Record<string, unknown>): st
1171
1552
  return typeof sourceEventId === "string" && sourceEventId.length > 0 ? sourceEventId : null;
1172
1553
  }
1173
1554
 
1174
- function shouldWithholdDeleteCascadeEvent(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
1555
+ function shouldWithholdDeleteCascadeEvent(
1556
+ db: Database,
1557
+ event: StoredEvent,
1558
+ fields: Record<string, unknown>,
1559
+ scope: ConflictScope,
1560
+ ): boolean {
1175
1561
  const sourceEventId = pendingDeleteConflictSourceEventId(fields);
1176
1562
  if (!sourceEventId) {
1177
1563
  return false;
1178
1564
  }
1179
1565
 
1180
- const isDeleteCascadeEvent = event.operation === "dependency.removed" || event.operation === "subtask.deleted";
1566
+ const isDeleteCascadeEvent =
1567
+ event.operation === "dependency.removed"
1568
+ || event.operation === "subtask.deleted"
1569
+ || event.operation === "task.deleted";
1181
1570
  if (!isDeleteCascadeEvent) {
1182
1571
  return false;
1183
1572
  }
1184
1573
 
1185
- return hasPendingDeleteConflict(db, sourceEventId);
1574
+ return hasPendingDeleteConflict(db, sourceEventId, scope);
1186
1575
  }
1187
1576
 
1188
- function applyEntityFields(db: Database, event: StoredEvent, fields: Record<string, unknown>): boolean {
1577
+ function applyEntityFields(
1578
+ db: Database,
1579
+ event: StoredEvent,
1580
+ fields: Record<string, unknown>,
1581
+ scope: ConflictScope,
1582
+ ): boolean {
1189
1583
  if (event.operation.endsWith(".deleted") || event.operation === "dependency.removed") {
1190
- return applyDelete(db, event, fields);
1584
+ return applyDelete(db, event, fields, scope);
1191
1585
  }
1192
1586
 
1193
1587
  if (event.operation.endsWith(".created") || event.operation === "dependency.added") {
@@ -1277,7 +1671,7 @@ export function syncStatus(cwd: string, sourceBranch: string): SyncStatusSummary
1277
1671
  sourceBranch,
1278
1672
  ahead: countAhead(storage.db, git.branchName, sourceBranch),
1279
1673
  behind: onSourceBranch ? 0 : countBranchEventsSince(storage.db, sourceBranch, cursorToken),
1280
- pendingConflicts: countPendingConflicts(storage.db),
1674
+ pendingConflicts: countPendingConflicts(storage.db, scopeFromGitContext(git)),
1281
1675
  sameBranch: onSourceBranch,
1282
1676
  git,
1283
1677
  };
@@ -1303,32 +1697,44 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
1303
1697
  let lastEventAt: number | null = cursor?.last_event_at ?? null;
1304
1698
  let scannedEvents = 0;
1305
1699
 
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
- }
1700
+ // Chunked write transactions: each batch of SYNC_PULL_BATCH_SIZE
1701
+ // events is committed in its own transaction so the write lock is
1702
+ // never held across multiple batches. On crash, the cursor reflects
1703
+ // the last fully-committed batch and the next pull resumes there.
1704
+ while (true) {
1705
+ const incomingEvents = queryBranchEventsSinceBatch(
1706
+ storage.db,
1707
+ sourceBranch,
1708
+ lastToken ?? cursorToken,
1709
+ SYNC_PULL_BATCH_SIZE,
1710
+ ) as StoredEvent[];
1318
1711
 
1319
- scannedEvents += incomingEvents.length;
1712
+ if (incomingEvents.length === 0) {
1713
+ break;
1714
+ }
1320
1715
 
1716
+ const batchResult = writeTransaction(storage.db, (): { token: string | null; eventAt: number | null } => {
1717
+ let token: string | null = lastToken;
1718
+ let eventAt: number | null = lastEventAt;
1321
1719
  for (const incoming of incomingEvents) {
1322
1720
  storeEvent(storage.db, incoming);
1323
- lastToken = cursorTokenFromEvent(incoming);
1324
- lastEventAt = incoming.created_at;
1721
+ token = cursorTokenFromEvent(incoming);
1722
+ eventAt = incoming.created_at;
1325
1723
  }
1326
- }
1724
+ if (token) {
1725
+ saveCursor(storage.db, git.worktreePath, sourceBranch, token, eventAt);
1726
+ }
1727
+ return { token, eventAt };
1728
+ });
1729
+
1730
+ scannedEvents += incomingEvents.length;
1731
+ lastToken = batchResult.token;
1732
+ lastEventAt = batchResult.eventAt;
1327
1733
 
1328
- if (lastToken) {
1329
- saveCursor(storage.db, git.worktreePath, sourceBranch, lastToken, lastEventAt);
1734
+ if (incomingEvents.length < SYNC_PULL_BATCH_SIZE) {
1735
+ break;
1330
1736
  }
1331
- });
1737
+ }
1332
1738
 
1333
1739
  return {
1334
1740
  sourceBranch,
@@ -1360,71 +1766,106 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
1360
1766
  let lastEventAt: number | null = cursor?.last_event_at ?? null;
1361
1767
  let scannedEvents = 0;
1362
1768
 
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[];
1769
+ // Per-pull memoization for "ours" field-value lookups. Reused across
1770
+ // every incoming event so repeated probes of the same (entity, field)
1771
+ // are O(1) after first hit.
1772
+ const oursCache = createOursValueCache();
1773
+
1774
+ // Conflict scope: every conflict / cleanup created by this pull is
1775
+ // tagged with the current worktree+branch so peer worktrees observing
1776
+ // the same entity own their own row set and cannot erase each other.
1777
+ const conflictScope: ConflictScope = scopeFromGitContext(git);
1778
+
1779
+ // Chunked write transactions: each batch of SYNC_PULL_BATCH_SIZE
1780
+ // events is processed inside its own writeTransaction. The cursor and
1781
+ // lastEventAt are persisted at the end of each batch, so a crash mid-
1782
+ // pull leaves a consistent cursor pointing at the last fully-committed
1783
+ // batch and the next pull resumes from there. The write lock is no
1784
+ // longer held across multiple batches.
1785
+ while (true) {
1786
+ const incomingEvents = queryBranchEventsSinceBatch(
1787
+ storage.db,
1788
+ sourceBranch,
1789
+ lastToken ?? cursorToken,
1790
+ SYNC_PULL_BATCH_SIZE,
1791
+ ) as StoredEvent[];
1371
1792
 
1372
- if (incomingEvents.length === 0) {
1373
- break;
1374
- }
1793
+ if (incomingEvents.length === 0) {
1794
+ break;
1795
+ }
1375
1796
 
1376
- scannedEvents += incomingEvents.length;
1797
+ interface BatchResult {
1798
+ readonly appliedDelta: number;
1799
+ readonly createdConflictsDelta: number;
1800
+ readonly malformedPayloadDelta: number;
1801
+ readonly applyRejectedDelta: number;
1802
+ readonly quarantinedDelta: number;
1803
+ readonly conflictEventsDelta: number;
1804
+ readonly token: string | null;
1805
+ readonly eventAt: number | null;
1806
+ }
1807
+
1808
+ const batchResult: BatchResult = writeTransaction(storage.db, (): BatchResult => {
1809
+ let appliedDelta = 0;
1810
+ let createdConflictsDelta = 0;
1811
+ let malformedPayloadDelta = 0;
1812
+ let applyRejectedDelta = 0;
1813
+ let quarantinedDelta = 0;
1814
+ let conflictEventsDelta = 0;
1815
+ let token: string | null = lastToken;
1816
+ let eventAt: number | null = lastEventAt;
1377
1817
 
1378
1818
  for (const incoming of incomingEvents) {
1379
1819
  if (incoming.operation === "resolve_conflict") {
1380
- if (applyIncomingResolutionEvent(storage.db, incoming)) {
1381
- appliedEvents += 1;
1820
+ if (applyIncomingResolutionEvent(storage.db, incoming, conflictScope)) {
1821
+ appliedDelta += 1;
1382
1822
  }
1383
1823
  storeEvent(storage.db, incoming);
1384
- lastToken = cursorTokenFromEvent(incoming);
1385
- lastEventAt = incoming.created_at;
1824
+ token = cursorTokenFromEvent(incoming);
1825
+ eventAt = incoming.created_at;
1386
1826
  continue;
1387
1827
  }
1388
1828
 
1389
1829
  const payloadValidation = parsePayload(incoming.payload);
1390
1830
 
1391
1831
  if (!payloadValidation.ok) {
1392
- malformedPayloadEvents += 1;
1393
- quarantinedEvents += 1;
1832
+ malformedPayloadDelta += 1;
1833
+ quarantinedDelta += 1;
1394
1834
  createConflict(
1395
1835
  storage.db,
1396
1836
  incoming,
1397
1837
  "__payload__",
1398
1838
  null,
1399
1839
  payloadValidation.reason ?? "Invalid payload",
1840
+ conflictScope,
1400
1841
  "invalid",
1401
1842
  );
1402
- createdConflicts += 1;
1843
+ createdConflictsDelta += 1;
1403
1844
  storeEvent(storage.db, incoming);
1404
- lastToken = cursorTokenFromEvent(incoming);
1405
- lastEventAt = incoming.created_at;
1845
+ token = cursorTokenFromEvent(incoming);
1846
+ eventAt = incoming.created_at;
1406
1847
  continue;
1407
1848
  }
1408
1849
 
1409
1850
  const payload: EventPayload = { fields: payloadValidation.fields };
1410
1851
 
1411
- if (shouldWithholdDeleteCascadeEvent(storage.db, incoming, payload.fields)) {
1852
+ if (shouldWithholdDeleteCascadeEvent(storage.db, incoming, payload.fields, conflictScope)) {
1412
1853
  storeEvent(storage.db, incoming);
1413
- lastToken = cursorTokenFromEvent(incoming);
1414
- lastEventAt = incoming.created_at;
1854
+ token = cursorTokenFromEvent(incoming);
1855
+ eventAt = incoming.created_at;
1415
1856
  continue;
1416
1857
  }
1417
1858
 
1418
1859
  const isDeleteWithLocalEdits =
1419
- (incoming.operation.endsWith(".deleted") && hasLocalDeleteCascadeEdits(storage.db, incoming, sourceBranch)) ||
1420
- (incoming.operation === "dependency.removed" && hasLocalDependencyDeleteConflict(storage.db, incoming, sourceBranch));
1860
+ (incoming.operation.endsWith(".deleted") && hasLocalDeleteCascadeEdits(storage.db, incoming, git.branchName)) ||
1861
+ (incoming.operation === "dependency.removed" && hasLocalDependencyDeleteConflict(storage.db, incoming, git.branchName));
1421
1862
  if (isDeleteWithLocalEdits) {
1422
- createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch");
1423
- createdConflicts += 1;
1424
- conflictEvents += 1;
1863
+ createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch", conflictScope);
1864
+ createdConflictsDelta += 1;
1865
+ conflictEventsDelta += 1;
1425
1866
  storeEvent(storage.db, incoming);
1426
- lastToken = cursorTokenFromEvent(incoming);
1427
- lastEventAt = incoming.created_at;
1867
+ token = cursorTokenFromEvent(incoming);
1868
+ eventAt = incoming.created_at;
1428
1869
  continue;
1429
1870
  }
1430
1871
 
@@ -1437,47 +1878,88 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
1437
1878
  continue;
1438
1879
  }
1439
1880
 
1440
- const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
1881
+ const conflict = entityFieldConflict(
1882
+ storage.db,
1883
+ git.branchName,
1884
+ sourceBranch,
1885
+ incoming,
1886
+ fieldName,
1887
+ value,
1888
+ oursCache,
1889
+ );
1441
1890
 
1442
1891
  if (conflict) {
1443
1892
  withheldConflictCount += 1;
1444
- conflictEvents += 1;
1445
- createConflict(storage.db, incoming, fieldName, conflict.oursValue, conflict.theirsValue);
1446
- createdConflicts += 1;
1893
+ conflictEventsDelta += 1;
1894
+ createConflict(
1895
+ storage.db,
1896
+ incoming,
1897
+ fieldName,
1898
+ conflict.oursValue,
1899
+ conflict.theirsValue,
1900
+ conflictScope,
1901
+ );
1902
+ createdConflictsDelta += 1;
1447
1903
  continue;
1448
1904
  }
1449
1905
 
1450
1906
  fieldsToApply[fieldName] = value;
1451
1907
  }
1452
1908
 
1453
- if (applyEntityFields(storage.db, incoming, fieldsToApply)) {
1454
- appliedEvents += 1;
1909
+ if (applyEntityFields(storage.db, incoming, fieldsToApply, conflictScope)) {
1910
+ appliedDelta += 1;
1455
1911
  } else if (applyReplayedCreateWithConflicts(storage.db, incoming, fieldsToApply, withheldConflictCount)) {
1456
- appliedEvents += 1;
1912
+ appliedDelta += 1;
1457
1913
  } else {
1458
- applyRejectedEvents += 1;
1459
- quarantinedEvents += 1;
1914
+ applyRejectedDelta += 1;
1915
+ quarantinedDelta += 1;
1460
1916
  createConflict(
1461
1917
  storage.db,
1462
1918
  incoming,
1463
1919
  "__apply__",
1464
1920
  null,
1465
1921
  `Rejected event ${incoming.operation} for ${incoming.entity_kind}`,
1922
+ conflictScope,
1466
1923
  "invalid",
1467
1924
  );
1468
- createdConflicts += 1;
1925
+ createdConflictsDelta += 1;
1469
1926
  }
1470
1927
 
1471
1928
  storeEvent(storage.db, incoming);
1472
- lastToken = cursorTokenFromEvent(incoming);
1473
- lastEventAt = incoming.created_at;
1929
+ token = cursorTokenFromEvent(incoming);
1930
+ eventAt = incoming.created_at;
1474
1931
  }
1475
- }
1476
1932
 
1477
- if (lastToken) {
1478
- saveCursor(storage.db, git.worktreePath, sourceBranch, lastToken, lastEventAt);
1933
+ if (token) {
1934
+ saveCursor(storage.db, git.worktreePath, sourceBranch, token, eventAt);
1935
+ }
1936
+
1937
+ return {
1938
+ appliedDelta,
1939
+ createdConflictsDelta,
1940
+ malformedPayloadDelta,
1941
+ applyRejectedDelta,
1942
+ quarantinedDelta,
1943
+ conflictEventsDelta,
1944
+ token,
1945
+ eventAt,
1946
+ };
1947
+ });
1948
+
1949
+ scannedEvents += incomingEvents.length;
1950
+ appliedEvents += batchResult.appliedDelta;
1951
+ createdConflicts += batchResult.createdConflictsDelta;
1952
+ malformedPayloadEvents += batchResult.malformedPayloadDelta;
1953
+ applyRejectedEvents += batchResult.applyRejectedDelta;
1954
+ quarantinedEvents += batchResult.quarantinedDelta;
1955
+ conflictEvents += batchResult.conflictEventsDelta;
1956
+ lastToken = batchResult.token;
1957
+ lastEventAt = batchResult.eventAt;
1958
+
1959
+ if (incomingEvents.length < SYNC_PULL_BATCH_SIZE) {
1960
+ break;
1479
1961
  }
1480
- });
1962
+ }
1481
1963
 
1482
1964
  const errorHints: string[] = buildSyncErrorHints({
1483
1965
  malformedPayloadEvents,
@@ -1651,6 +2133,12 @@ function appendResolutionEvent(
1651
2133
  field: conflict.field_name,
1652
2134
  resolution,
1653
2135
  value: resolvedValue,
2136
+ // Embed the resolved row's scope so receivers can audit which
2137
+ // worktree/branch performed the resolution. Receivers still
2138
+ // intersect against their OWN active scope when looking up the
2139
+ // local row to mutate.
2140
+ worktree_path: conflict.worktree_path,
2141
+ current_branch: conflict.current_branch,
1654
2142
  }),
1655
2143
  gitBranch,
1656
2144
  gitHead,
@@ -1661,19 +2149,30 @@ function appendResolutionEvent(
1661
2149
 
1662
2150
  export function listSyncConflicts(cwd: string, mode: SyncConflictMode): SyncConflictListItem[] {
1663
2151
  const storage = openTrekoonDatabase(cwd);
2152
+ const git = resolveGitContext(cwd);
2153
+ const scope: ConflictScope = scopeFromGitContext(git);
1664
2154
 
1665
2155
  try {
1666
- const whereClause = mode === "pending" ? "WHERE resolution = 'pending'" : "";
2156
+ // Conflicts are scoped to the worktree+branch that recorded them. Each
2157
+ // worktree only sees its own pending/resolved conflicts so peer
2158
+ // worktrees on the same shared DB don't bleed into one another.
2159
+ const conditions: string[] = ["worktree_path = ?", "current_branch = ?"];
2160
+ const params: string[] = [scope.worktreePath, scope.currentBranch];
2161
+
2162
+ if (mode === "pending") {
2163
+ conditions.push("resolution = 'pending'");
2164
+ }
2165
+
1667
2166
  return storage.db
1668
2167
  .query(
1669
2168
  `
1670
- SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
2169
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
1671
2170
  FROM sync_conflicts
1672
- ${whereClause}
2171
+ WHERE ${conditions.join(" AND ")}
1673
2172
  ORDER BY created_at ASC;
1674
2173
  `,
1675
2174
  )
1676
- .all() as SyncConflictListItem[];
2175
+ .all(...params) as SyncConflictListItem[];
1677
2176
  } finally {
1678
2177
  storage.close();
1679
2178
  }
@@ -1681,18 +2180,22 @@ export function listSyncConflicts(cwd: string, mode: SyncConflictMode): SyncConf
1681
2180
 
1682
2181
  export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDetail {
1683
2182
  const storage = openTrekoonDatabase(cwd);
2183
+ const git = resolveGitContext(cwd);
2184
+ const scope: ConflictScope = scopeFromGitContext(git);
1684
2185
 
1685
2186
  try {
1686
2187
  const conflict = storage.db
1687
2188
  .query(
1688
2189
  `
1689
- SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
2190
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
1690
2191
  FROM sync_conflicts
1691
2192
  WHERE id = ?
2193
+ AND worktree_path = ?
2194
+ AND current_branch = ?
1692
2195
  LIMIT 1;
1693
2196
  `,
1694
2197
  )
1695
- .get(conflictId) as ConflictRow | null;
2198
+ .get(conflictId, scope.worktreePath, scope.currentBranch) as ConflictRow | null;
1696
2199
 
1697
2200
  if (!conflict) {
1698
2201
  throw new Error(`Conflict '${conflictId}' not found.`);
@@ -1736,17 +2239,19 @@ export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDe
1736
2239
  }
1737
2240
  }
1738
2241
 
1739
- function lookupPendingConflict(db: Database, conflictId: string): ConflictRow {
2242
+ function lookupPendingConflict(db: Database, conflictId: string, scope: ConflictScope): ConflictRow {
1740
2243
  const conflict = db
1741
2244
  .query(
1742
2245
  `
1743
- SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
2246
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
1744
2247
  FROM sync_conflicts
1745
2248
  WHERE id = ?
2249
+ AND worktree_path = ?
2250
+ AND current_branch = ?
1746
2251
  LIMIT 1;
1747
2252
  `,
1748
2253
  )
1749
- .get(conflictId) as ConflictRow | null;
2254
+ .get(conflictId, scope.worktreePath, scope.currentBranch) as ConflictRow | null;
1750
2255
 
1751
2256
  if (!conflict) {
1752
2257
  throw new Error(`Conflict '${conflictId}' not found.`);
@@ -1762,6 +2267,7 @@ function lookupPendingConflict(db: Database, conflictId: string): ConflictRow {
1762
2267
  export function syncResolve(cwd: string, conflictId: string, resolution: SyncResolution): ResolveSummary {
1763
2268
  const storage = openTrekoonDatabase(cwd);
1764
2269
  const git = resolveGitContext(cwd);
2270
+ const scope: ConflictScope = scopeFromGitContext(git);
1765
2271
 
1766
2272
  try {
1767
2273
  persistGitContext(storage.db, git);
@@ -1771,7 +2277,7 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
1771
2277
  // atomic. Without this, two concurrent resolves could both pass
1772
2278
  // the check and double-resolve the same conflict.
1773
2279
  const conflict = writeTransaction(storage.db, (): ConflictRow => {
1774
- const row = lookupPendingConflict(storage.db, conflictId);
2280
+ const row = lookupPendingConflict(storage.db, conflictId, scope);
1775
2281
  resolveConflictRow(storage.db, row, resolution, git);
1776
2282
  return row;
1777
2283
  });
@@ -1791,9 +2297,11 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
1791
2297
  // Preview is read-only — no git context persistence needed.
1792
2298
  export function syncResolvePreview(cwd: string, conflictId: string, resolution: SyncResolution): ResolvePreviewSummary {
1793
2299
  const storage = openTrekoonDatabase(cwd);
2300
+ const git = resolveGitContext(cwd);
2301
+ const scope: ConflictScope = scopeFromGitContext(git);
1794
2302
 
1795
2303
  try {
1796
- const conflict = lookupPendingConflict(storage.db, conflictId);
2304
+ const conflict = lookupPendingConflict(storage.db, conflictId, scope);
1797
2305
 
1798
2306
  const oursValue: unknown = parseConflictValue(conflict.ours_value);
1799
2307
  const theirsValue: unknown = parseConflictValue(conflict.theirs_value);
@@ -1818,17 +2326,22 @@ export function syncResolvePreview(cwd: string, conflictId: string, resolution:
1818
2326
  function queryPendingConflictIds(
1819
2327
  db: Database,
1820
2328
  filters: ResolveAllQueryFilters,
2329
+ scope: ConflictScope,
1821
2330
  ): readonly string[] {
1822
- const conditions: string[] = ["resolution = 'pending'"];
1823
- const params: string[] = [];
2331
+ const conditions: string[] = [
2332
+ "c.resolution = 'pending'",
2333
+ "c.worktree_path = ?",
2334
+ "c.current_branch = ?",
2335
+ ];
2336
+ const params: string[] = [scope.worktreePath, scope.currentBranch];
1824
2337
 
1825
2338
  if (filters.entityId !== undefined) {
1826
- conditions.push("entity_id = ?");
2339
+ conditions.push("c.entity_id = ?");
1827
2340
  params.push(filters.entityId);
1828
2341
  }
1829
2342
 
1830
2343
  if (filters.fieldName !== undefined) {
1831
- conditions.push("field_name = ?");
2344
+ conditions.push("c.field_name = ?");
1832
2345
  params.push(filters.fieldName);
1833
2346
  }
1834
2347
 
@@ -1836,14 +2349,18 @@ function queryPendingConflictIds(
1836
2349
  SELECT c.id
1837
2350
  FROM sync_conflicts c
1838
2351
  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 ")}
2352
+ WHERE ${conditions.join(" AND ")}
1840
2353
  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
2354
  `;
1842
2355
 
1843
2356
  return (db.query(sql).all(...params) as ConflictOrderRow[]).map((row) => row.id);
1844
2357
  }
1845
2358
 
1846
- function queryPendingConflictsByIds(db: Database, conflictIds: readonly string[]): readonly ConflictRow[] {
2359
+ function queryPendingConflictsByIds(
2360
+ db: Database,
2361
+ conflictIds: readonly string[],
2362
+ scope: ConflictScope,
2363
+ ): readonly ConflictRow[] {
1847
2364
  if (conflictIds.length === 0) {
1848
2365
  return [];
1849
2366
  }
@@ -1852,12 +2369,15 @@ function queryPendingConflictsByIds(db: Database, conflictIds: readonly string[]
1852
2369
  const rows = db
1853
2370
  .query(
1854
2371
  `
1855
- SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
2372
+ SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
1856
2373
  FROM sync_conflicts
1857
- WHERE resolution = 'pending' AND id IN (${placeholders});
2374
+ WHERE resolution = 'pending'
2375
+ AND id IN (${placeholders})
2376
+ AND worktree_path = ?
2377
+ AND current_branch = ?;
1858
2378
  `,
1859
2379
  )
1860
- .all(...conflictIds) as ConflictRow[];
2380
+ .all(...conflictIds, scope.worktreePath, scope.currentBranch) as ConflictRow[];
1861
2381
 
1862
2382
  const rowById = new Map(rows.map((row) => [row.id, row]));
1863
2383
 
@@ -1880,9 +2400,10 @@ export function syncResolveAll(
1880
2400
  try {
1881
2401
  persistGitContext(storage.db, git);
1882
2402
 
2403
+ const scope: ConflictScope = scopeFromGitContext(git);
1883
2404
  const resolvedIds: string[] = writeTransaction(storage.db, (): string[] => {
1884
2405
  const expectedConflictIds = options.expectedConflictIds;
1885
- const orderedConflictIds = expectedConflictIds ?? queryPendingConflictIds(storage.db, filters);
2406
+ const orderedConflictIds = expectedConflictIds ?? queryPendingConflictIds(storage.db, filters, scope);
1886
2407
 
1887
2408
  if (orderedConflictIds.length === 0) {
1888
2409
  throw new DomainError({
@@ -1896,7 +2417,7 @@ export function syncResolveAll(
1896
2417
 
1897
2418
  for (let offset = 0; offset < orderedConflictIds.length; offset += RESOLVE_ALL_CHUNK_SIZE) {
1898
2419
  const chunkIds = orderedConflictIds.slice(offset, offset + RESOLVE_ALL_CHUNK_SIZE);
1899
- const chunkConflicts = queryPendingConflictsByIds(storage.db, chunkIds);
2420
+ const chunkConflicts = queryPendingConflictsByIds(storage.db, chunkIds, scope);
1900
2421
 
1901
2422
  if (chunkConflicts.length !== chunkIds.length) {
1902
2423
  throw new DomainError({
@@ -1936,10 +2457,12 @@ export function syncResolveAllPreview(
1936
2457
  filters: ResolveAllQueryFilters,
1937
2458
  ): ResolveAllPreviewSummary {
1938
2459
  const storage = openTrekoonDatabase(cwd);
2460
+ const git = resolveGitContext(cwd);
2461
+ const scope: ConflictScope = scopeFromGitContext(git);
1939
2462
  const normalizedFilters: ResolveAllFilters = normalizeResolveAllFilters(filters);
1940
2463
 
1941
2464
  try {
1942
- const conflictIds = queryPendingConflictIds(storage.db, filters);
2465
+ const conflictIds = queryPendingConflictIds(storage.db, filters, scope);
1943
2466
 
1944
2467
  if (conflictIds.length === 0) {
1945
2468
  throw new DomainError({