@markjaquith/agency 3.13.0 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.13.0",
3
+ "version": "3.14.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -138,6 +138,7 @@ Options:
138
138
 
139
139
  Sync may materialize declared repositories and unambiguous missing checkouts,
140
140
  adopt legacy repositories with portable origins, record a uniquely matched PR,
141
- and mark merged work done. Dirty, stale, or
141
+ adopt a recorded PR's retargeted base or renamed head branch, and mark merged
142
+ work done. Dirty, stale, or
142
143
  conflicting checkouts are always left unresolved.
143
144
  `
@@ -58,7 +58,12 @@ interface RegisteredWorktree {
58
58
  }
59
59
 
60
60
  interface SyncChange {
61
- readonly kind: "materialize-workspace" | "record-pr" | "mark-done"
61
+ readonly kind:
62
+ | "materialize-workspace"
63
+ | "adopt-pr-branch"
64
+ | "adopt-pr-base"
65
+ | "record-pr"
66
+ | "mark-done"
62
67
  readonly target: string
63
68
  readonly message: string
64
69
  readonly status: "planned" | "applied"
@@ -162,14 +167,34 @@ interface PullRequestQuery {
162
167
  | undefined
163
168
  }
164
169
 
170
+ const canonicalRecord = (record: PullRequestRecord | null) =>
171
+ record
172
+ ? JSON.stringify(
173
+ Object.entries(record)
174
+ .filter(([, value]) => value !== undefined)
175
+ .sort(([a], [b]) => a.localeCompare(b)),
176
+ )
177
+ : null
178
+
179
+ const samePullRequestRecord = (
180
+ current: PullRequestRecord,
181
+ existing: PullRequestRecord | null,
182
+ ) => canonicalRecord(current) === canonicalRecord(existing)
183
+
184
+ const belongsToWritableRepository = (
185
+ current: PullRequestRecord,
186
+ remoteRepository: string,
187
+ ) =>
188
+ current.headRepository?.toLowerCase() === remoteRepository.toLowerCase() &&
189
+ current.baseRepository?.toLowerCase() === current.repository.toLowerCase()
190
+
165
191
  const mergedPullRequestFromGitHub = (
166
192
  data: ExecutionData,
167
193
  query: PullRequestQuery | undefined,
168
194
  details: readonly PullRequestRecord[] | undefined,
169
195
  ) => {
170
196
  if (!query?.result || query.result.exitCode !== 0 || !details) return null
171
- const existing = data.pr ? normalizePullRequestRecord(data.pr) : null
172
- const matches = existing
197
+ const matches = data.pr
173
198
  ? details
174
199
  : details.filter(
175
200
  (item) =>
@@ -177,18 +202,10 @@ const mergedPullRequestFromGitHub = (
177
202
  )
178
203
  if (matches.length !== 1) return null
179
204
  const current = matches[0]!
180
- if (
181
- current.merged !== true ||
182
- current.headRepository?.toLowerCase() !==
183
- query.remoteRepository.toLowerCase() ||
184
- current.headBranch !== data.branch ||
185
- current.baseRepository?.toLowerCase() !==
186
- current.repository.toLowerCase() ||
187
- current.baseBranch !== data.base
188
- ) {
189
- return null
190
- }
191
- return current
205
+ return current.merged === true &&
206
+ belongsToWritableRepository(current, query.remoteRepository)
207
+ ? current
208
+ : null
192
209
  }
193
210
 
194
211
  const mutateExecution = (
@@ -650,6 +667,22 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
650
667
  { concurrency: 8 },
651
668
  )
652
669
 
670
+ const branchOwners = new Map<string, string>()
671
+ const branchKey = (repo: string, branch: string) =>
672
+ JSON.stringify([repo, branch])
673
+ for (const task of allTaskRecords) {
674
+ if ("repo" in task.data)
675
+ branchOwners.set(
676
+ branchKey(task.data.repo, task.data.branch),
677
+ `task:${task.id}`,
678
+ )
679
+ for (const phase of documents.phasesByTask.get(task.id) ?? [])
680
+ branchOwners.set(
681
+ branchKey(phase.data.repo, phase.data.branch),
682
+ `phase:${task.id}/${phase.id}`,
683
+ )
684
+ }
685
+
653
686
  for (const record of records.sort((a, b) =>
654
687
  a.key.localeCompare(b.key),
655
688
  )) {
@@ -662,6 +695,126 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
662
695
  const githubDetails = githubResponse?.ok
663
696
  ? githubResponse.details
664
697
  : undefined
698
+ let renamedFrom: string | null = null
699
+ let adoptionConflict = false
700
+ const recordedPr =
701
+ !config.delivery && data.pr ? githubDetails?.[0] : undefined
702
+ if (
703
+ query &&
704
+ recordedPr?.headBranch &&
705
+ recordedPr.baseBranch &&
706
+ belongsToWritableRepository(recordedPr, query.remoteRepository) &&
707
+ (recordedPr.headBranch !== data.branch ||
708
+ recordedPr.baseBranch !== data.base)
709
+ ) {
710
+ const previous = { branch: data.branch, base: data.base }
711
+ const adopted = {
712
+ branch: recordedPr.headBranch,
713
+ base: recordedPr.baseBranch,
714
+ }
715
+ const repositoryPath = join(root, "repos", data.repo)
716
+ let renameLocal = false
717
+ if (adopted.branch !== previous.branch) {
718
+ const owner = branchOwners.get(
719
+ branchKey(data.repo, adopted.branch),
720
+ )
721
+ const [previousLocal, adoptedLocal] = yield* Effect.all([
722
+ backend.resolveRevision(
723
+ repositoryPath,
724
+ `refs/heads/${previous.branch}`,
725
+ ),
726
+ backend.resolveRevision(
727
+ repositoryPath,
728
+ `refs/heads/${adopted.branch}`,
729
+ ),
730
+ ])
731
+ if (owner && owner !== record.key) {
732
+ adoptionConflict = true
733
+ unresolved.push({
734
+ kind: "pr-branch-conflict",
735
+ target: record.key,
736
+ message: `Recorded PR head '${adopted.branch}' is declared by '${owner}'`,
737
+ action:
738
+ "Correct the recorded PR URL or the other declaration",
739
+ })
740
+ } else if (previousLocal && adoptedLocal) {
741
+ adoptionConflict = true
742
+ unresolved.push({
743
+ kind: "pr-branch-conflict",
744
+ target: record.key,
745
+ message: `Cannot rename local branch '${previous.branch}' to recorded PR head '${adopted.branch}' because it already exists`,
746
+ action: `Reconcile or delete local branch '${adopted.branch}' manually`,
747
+ })
748
+ } else {
749
+ renameLocal = previousLocal !== null
750
+ }
751
+ }
752
+ if (!adoptionConflict && apply) {
753
+ const renameBranch = (from: string, to: string) =>
754
+ runExternal(["git", "branch", "-m", from, to], {
755
+ cwd: repositoryPath,
756
+ })
757
+ const renamed = renameLocal
758
+ ? yield* renameBranch(previous.branch, adopted.branch)
759
+ : null
760
+ if (renamed && renamed.exitCode !== 0) {
761
+ adoptionConflict = true
762
+ unresolved.push({
763
+ kind: "pr-branch-rename-failed",
764
+ target: record.key,
765
+ message: `Could not rename local branch '${previous.branch}' to '${adopted.branch}': ${commandErrorSummary(renamed.stderr, "git branch failed")}`,
766
+ action: "Rename the local branch manually",
767
+ })
768
+ } else {
769
+ const updated = yield* mutateExecution(root, record, revision, {
770
+ ...data,
771
+ ...adopted,
772
+ }).pipe(
773
+ Effect.tapError(() =>
774
+ renamed
775
+ ? renameBranch(adopted.branch, previous.branch)
776
+ : Effect.void,
777
+ ),
778
+ )
779
+ data = updated.data
780
+ revision = updated.revision
781
+ const cached = registeredByRepository.get(repositoryPath)
782
+ for (const [index, item] of (cached ?? []).entries()) {
783
+ if (item.branch === `refs/heads/${previous.branch}`)
784
+ cached![index] = {
785
+ ...item,
786
+ branch: `refs/heads/${adopted.branch}`,
787
+ }
788
+ }
789
+ }
790
+ } else if (!adoptionConflict) {
791
+ data = { ...data, ...adopted }
792
+ if (renameLocal) renamedFrom = previous.branch
793
+ }
794
+ if (!adoptionConflict) {
795
+ if (adopted.branch !== previous.branch) {
796
+ branchOwners.delete(branchKey(data.repo, previous.branch))
797
+ branchOwners.set(
798
+ branchKey(data.repo, adopted.branch),
799
+ record.key,
800
+ )
801
+ changes.push({
802
+ kind: "adopt-pr-branch",
803
+ target: record.key,
804
+ message: `Adopt pull request head '${adopted.branch}' (was '${previous.branch}')`,
805
+ status: apply ? "applied" : "planned",
806
+ })
807
+ }
808
+ if (adopted.base !== previous.base) {
809
+ changes.push({
810
+ kind: "adopt-pr-base",
811
+ target: record.key,
812
+ message: `Adopt pull request base '${adopted.base}' (was '${previous.base}')`,
813
+ status: apply ? "applied" : "planned",
814
+ })
815
+ }
816
+ }
817
+ }
665
818
  const remoteMergedPr = config.delivery
666
819
  ? null
667
820
  : mergedPullRequestFromGitHub(data, query, githubDetails)
@@ -838,7 +991,12 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
838
991
  action: "Review and preserve or discard local changes manually",
839
992
  })
840
993
  }
841
- if (atPath && branchRef && atPath.branch !== branchRef) {
994
+ if (
995
+ atPath &&
996
+ branchRef &&
997
+ atPath.branch !== branchRef &&
998
+ !(renamedFrom && atPath.branch === `refs/heads/${renamedFrom}`)
999
+ ) {
842
1000
  unresolved.push({
843
1001
  kind: "wrong-branch",
844
1002
  target: record.key,
@@ -1022,21 +1180,26 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1022
1180
  if (githubDetails) {
1023
1181
  current = githubDetails[0]!
1024
1182
  pr = current
1025
- if (
1026
- current.headRepository?.toLowerCase() !==
1027
- remoteRepository.toLowerCase() ||
1028
- current.headBranch !== data.branch ||
1029
- current.baseRepository?.toLowerCase() !==
1030
- current.repository.toLowerCase() ||
1031
- current.baseBranch !== data.base
1032
- ) {
1183
+ if (!belongsToWritableRepository(current, remoteRepository)) {
1033
1184
  prConflict = true
1034
1185
  unresolved.push({
1035
1186
  kind: "pr-repository-conflict",
1036
1187
  target: record.key,
1037
- message: `Recorded PR head does not match '${remoteRepository}:${data.branch}' or base '${current.repository}:${data.base}'`,
1188
+ message: `Recorded PR repositories do not match writable repository '${remoteRepository}' and base '${current.repository}'`,
1038
1189
  action: "Correct the declaration or recorded PR URL",
1039
1190
  })
1191
+ } else if (
1192
+ current.headBranch !== data.branch ||
1193
+ current.baseBranch !== data.base
1194
+ ) {
1195
+ prConflict = true
1196
+ if (!adoptionConflict)
1197
+ unresolved.push({
1198
+ kind: "pr-branch-conflict",
1199
+ target: record.key,
1200
+ message: `Recorded PR head '${current.headBranch ?? "unknown"}' or base '${current.baseBranch ?? "unknown"}' could not be adopted`,
1201
+ action: "Correct the declaration or recorded PR URL",
1202
+ })
1040
1203
  }
1041
1204
  }
1042
1205
  } else {
@@ -1092,7 +1255,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1092
1255
  }
1093
1256
  }
1094
1257
 
1095
- if (current && JSON.stringify(current) !== JSON.stringify(existing)) {
1258
+ if (current && !samePullRequestRecord(current, existing)) {
1096
1259
  if (apply) {
1097
1260
  const recorded = yield* mutateExecution(root, record, revision, {
1098
1261
  ...data,