borgmcp 4.5.1 → 4.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,7 +44,7 @@ const OPEN_CODE_DIAGNOSTIC_LOG_MAX_BYTES = 64 * 1024;
44
44
  const diagnosticLogPathsForTests = new Set<string>();
45
45
 
46
46
  function stateIdentityDigest(current: OpenCodeDroneState): string {
47
- const key = [current.serverUrl, current.directory, current.cubeName, current.droneLabel].join('\0');
47
+ const key = [current.directory, current.cubeName, current.droneLabel].join('\0');
48
48
  return createHash('sha256').update(key).digest('hex').slice(0, 24);
49
49
  }
50
50
 
@@ -211,8 +211,6 @@ interface OCSession {
211
211
  directory: string;
212
212
  time: { created: number };
213
213
  parentID?: string;
214
- agent?: string;
215
- model?: { providerID: string; modelID: string };
216
214
  }
217
215
 
218
216
  interface OCMessage {
@@ -461,12 +459,6 @@ function decodeSession(value: unknown): OCSession {
461
459
  || typeof value.time.created !== 'number'
462
460
  || !Number.isFinite(value.time.created)
463
461
  || (value.parentID !== undefined && typeof value.parentID !== 'string')
464
- || (value.agent !== undefined && typeof value.agent !== 'string')
465
- || (value.model !== undefined && (
466
- !isRecord(value.model)
467
- || typeof value.model.providerID !== 'string'
468
- || typeof value.model.modelID !== 'string'
469
- ))
470
462
  ) {
471
463
  throw new OpenCodeResponseError();
472
464
  }
@@ -562,7 +554,6 @@ function bindingPath(): string {
562
554
  function bindingMatchesState(binding: SessionBinding): boolean {
563
555
  const current = state!;
564
556
  return binding.version === 4
565
- && binding.serverUrl === current.serverUrl
566
557
  && binding.directory === current.directory
567
558
  && binding.droneLabel === current.droneLabel
568
559
  && binding.cubeName === current.cubeName
@@ -747,35 +738,126 @@ function launchCorrelationMatchCount(messages: OCMessage[], correlationIdentity:
747
738
  * therefore allowed only when it received this launch's correlation metadata.
748
739
  */
749
740
  async function findLaunchSession(correlationIdentity: string): Promise<{
741
+ kind: 'found';
750
742
  session: OCSession;
751
743
  knownRootSessionIds: string[];
752
- } | null> {
744
+ } | {
745
+ kind: 'superseded';
746
+ } | {
747
+ kind: 'list-failed';
748
+ failureCode: string;
749
+ errorClass: string;
750
+ httpStatus: number | null;
751
+ } | {
752
+ kind: 'directory-miss';
753
+ listedCount: number;
754
+ directoryCount: 0;
755
+ } | {
756
+ kind: 'message-list-failed';
757
+ listedCount: number;
758
+ directoryCount: number;
759
+ failureCode: string;
760
+ errorClass: string;
761
+ httpStatus: number | null;
762
+ } | {
763
+ kind: 'correlation-mismatch';
764
+ listedCount: number;
765
+ directoryCount: number;
766
+ matchCount: number;
767
+ }> {
753
768
  const owner = state!;
754
769
  const observationSequence = ++owner.nextObservationSequence;
770
+ let listedSessions: OCSession[];
755
771
  try {
756
- const listedSessions = await listSessions();
757
- if (state !== owner) return null;
758
- const sessions = listedSessions.filter(
759
- (session) => session.directory === owner.directory,
760
- );
761
- const knownRootSessionIds = sessions
762
- .filter(isTopLevelSession)
763
- .map((session) => session.id);
764
- const candidates = await Promise.all(sessions.map(async (session) => ({
772
+ listedSessions = await listSessions();
773
+ } catch (error) {
774
+ if (state === owner) recordOpenCodeFailure(owner, error, observationSequence);
775
+ return { kind: 'list-failed', ...openCodeFailureDiagnostic(error) };
776
+ }
777
+ if (state !== owner) return { kind: 'superseded' };
778
+ const sessions = listedSessions.filter(
779
+ (session) => session.directory === owner.directory,
780
+ );
781
+ if (sessions.length === 0) {
782
+ return { kind: 'directory-miss', listedCount: listedSessions.length, directoryCount: 0 };
783
+ }
784
+ const knownRootSessionIds = sessions
785
+ .filter(isTopLevelSession)
786
+ .map((session) => session.id);
787
+ let candidates: Array<{ session: OCSession; matchCount: number }>;
788
+ try {
789
+ candidates = await Promise.all(sessions.map(async (session) => ({
765
790
  session,
766
791
  matchCount: launchCorrelationMatchCount(
767
792
  await listSessionMessages(session.id),
768
793
  correlationIdentity,
769
794
  ),
770
795
  })));
771
- if (state !== owner) return null;
772
- const totalMatches = candidates.reduce((total, candidate) => total + candidate.matchCount, 0);
773
- if (totalMatches !== 1) return null;
774
- const matched = candidates.find((candidate) => candidate.matchCount === 1);
775
- return matched ? { session: matched.session, knownRootSessionIds } : null;
776
796
  } catch (error) {
777
797
  if (state === owner) recordOpenCodeFailure(owner, error, observationSequence);
778
- return null;
798
+ return {
799
+ kind: 'message-list-failed',
800
+ listedCount: listedSessions.length,
801
+ directoryCount: sessions.length,
802
+ ...openCodeFailureDiagnostic(error),
803
+ };
804
+ }
805
+ if (state !== owner) return { kind: 'superseded' };
806
+ const totalMatches = candidates.reduce((total, candidate) => total + candidate.matchCount, 0);
807
+ if (totalMatches !== 1) {
808
+ return {
809
+ kind: 'correlation-mismatch',
810
+ listedCount: listedSessions.length,
811
+ directoryCount: sessions.length,
812
+ matchCount: totalMatches,
813
+ };
814
+ }
815
+ const matched = candidates.find((candidate) => candidate.matchCount === 1)!;
816
+ return { kind: 'found', session: matched.session, knownRootSessionIds };
817
+ }
818
+
819
+ type LaunchSessionSearchFailure = Exclude<Awaited<ReturnType<typeof findLaunchSession>>, {
820
+ kind: 'found' | 'superseded';
821
+ }>;
822
+
823
+ function logLaunchSessionSearchFailure(
824
+ failure: LaunchSessionSearchFailure,
825
+ attempts: number,
826
+ owner: OpenCodeDroneState,
827
+ ): void {
828
+ switch (failure.kind) {
829
+ case 'list-failed':
830
+ log(
831
+ `kickoff: session search list-failed code=${failure.failureCode} `
832
+ + `class=${failure.errorClass} status=${failure.httpStatus ?? 'none'} `
833
+ + `listed=unknown directory=unknown matches=unknown attempts=${attempts}`,
834
+ owner,
835
+ );
836
+ break;
837
+ case 'directory-miss':
838
+ log(
839
+ `kickoff: session search directory-miss listed=${failure.listedCount} `
840
+ + `directory=${failure.directoryCount} matches=0 attempts=${attempts}`,
841
+ owner,
842
+ );
843
+ break;
844
+ case 'message-list-failed':
845
+ log(
846
+ `kickoff: session search messages-failed code=${failure.failureCode} `
847
+ + `class=${failure.errorClass} status=${failure.httpStatus ?? 'none'} `
848
+ + `listed=${failure.listedCount} directory=${failure.directoryCount} `
849
+ + `matches=unknown attempts=${attempts}`,
850
+ owner,
851
+ );
852
+ break;
853
+ case 'correlation-mismatch':
854
+ log(
855
+ `kickoff: session search correlation-mismatch listed=${failure.listedCount} `
856
+ + `directory=${failure.directoryCount} `
857
+ + `matches=${failure.matchCount} attempts=${attempts}`,
858
+ owner,
859
+ );
860
+ break;
779
861
  }
780
862
  }
781
863
 
@@ -836,6 +918,18 @@ function openCodeFailureCode(error: unknown): string {
836
918
  return error instanceof Error && error.name ? error.name : 'unknown';
837
919
  }
838
920
 
921
+ function openCodeFailureDiagnostic(error: unknown): {
922
+ failureCode: string;
923
+ errorClass: string;
924
+ httpStatus: number | null;
925
+ } {
926
+ return {
927
+ failureCode: openCodeFailureCode(error),
928
+ errorClass: error instanceof Error && error.name ? error.name : 'unknown',
929
+ httpStatus: error instanceof OpenCodeHttpError ? error.status : null,
930
+ };
931
+ }
932
+
839
933
  function updateLastOpenCodeObservation(
840
934
  owner: OpenCodeDroneState,
841
935
  sequence: number,
@@ -1185,31 +1279,59 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
1185
1279
 
1186
1280
  try {
1187
1281
  // Wait for the server.
1282
+ let serverReady = false;
1283
+ let lastServerError: unknown;
1188
1284
  for (let i = 0; i < 30; i++) {
1189
1285
  try {
1190
1286
  await listSessions();
1287
+ if (state !== owner) return false;
1288
+ serverReady = true;
1191
1289
  log(`kickoff: server ready (attempt ${i + 1})`, owner);
1192
1290
  break;
1193
- } catch {
1194
- // not ready yet
1291
+ } catch (error) {
1292
+ if (state !== owner) return false;
1293
+ lastServerError = error;
1195
1294
  }
1196
- await new Promise((r) => setTimeout(r, 1000));
1295
+ if (i < 29) await new Promise((r) => setTimeout(r, 1000));
1296
+ }
1297
+ if (!serverReady) {
1298
+ const observationSequence = ++owner.nextObservationSequence;
1299
+ recordOpenCodeFailure(owner, lastServerError, observationSequence);
1300
+ const failure = openCodeFailureDiagnostic(lastServerError);
1301
+ log(
1302
+ `kickoff: server unavailable code=${failure.failureCode} `
1303
+ + `class=${failure.errorClass} status=${failure.httpStatus ?? 'none'} attempts=30`,
1304
+ owner,
1305
+ );
1306
+ return false;
1197
1307
  }
1198
1308
 
1199
1309
  // Capture the launch-selected session, including explicit resume/fork
1200
1310
  // targets. Unrelated sessions do not contain this launch's metadata identity.
1311
+ let lastSearchFailure: LaunchSessionSearchFailure | null = null;
1201
1312
  for (let i = 0; i < 30; i++) {
1202
- const binding = await findLaunchSession(launch.correlationIdentity);
1203
- if (binding) {
1204
- if (state !== owner) return false;
1205
- saveBinding(binding.session, binding.knownRootSessionIds);
1206
- log(`kickoff: bound session ${binding.session.id.slice(0, 8)}…`, owner);
1313
+ const result = await findLaunchSession(launch.correlationIdentity);
1314
+ if (result.kind === 'superseded') return false;
1315
+ if (result.kind === 'found') {
1316
+ saveBinding(result.session, result.knownRootSessionIds);
1317
+ log(`kickoff: bound session ${result.session.id.slice(0, 8)}…`, owner);
1207
1318
  return true;
1208
1319
  }
1209
- await new Promise((r) => setTimeout(r, 1000));
1320
+ lastSearchFailure = result;
1321
+ if (i < 29) await new Promise((r) => setTimeout(r, 1000));
1210
1322
  }
1211
1323
 
1212
- log('kickoff: no session found', owner);
1324
+ if (lastSearchFailure) {
1325
+ logLaunchSessionSearchFailure(lastSearchFailure, 30, owner);
1326
+ if (
1327
+ lastSearchFailure.kind === 'directory-miss'
1328
+ || lastSearchFailure.kind === 'correlation-mismatch'
1329
+ ) {
1330
+ updateLastOpenCodeObservation(owner, ++owner.nextObservationSequence, {
1331
+ lastFailureCode: 'no-target',
1332
+ });
1333
+ }
1334
+ }
1213
1335
  return false;
1214
1336
  } catch (err) {
1215
1337
  log(`kickoff error: ${err}`, owner);
@@ -1443,6 +1565,14 @@ export function __getOpenCodeLastObservationForTests(): OpenCodeLastObservation
1443
1565
  return { ...state.lastObservation };
1444
1566
  }
1445
1567
 
1568
+ export function __decodeOpenCodeSessionForTests(value: unknown): unknown {
1569
+ return decodeSession(value);
1570
+ }
1571
+
1572
+ export async function __listOpenCodeSessionsForTests(): Promise<unknown[]> {
1573
+ return listSessions();
1574
+ }
1575
+
1446
1576
  export function computeOpenCodePort(droneId: string, base: number = 14096): number {
1447
1577
  let hash = 0;
1448
1578
  for (let i = 0; i < droneId.length; i++) {
package/src/seats.ts CHANGED
@@ -50,6 +50,8 @@ export interface SeatRecord {
50
50
  sessionId?: string;
51
51
  // binding + display (set atomically at FINALIZE; absent while pending)
52
52
  worktree?: string;
53
+ commonDir?: string;
54
+ repositoryOrigin?: string;
53
55
  name?: string;
54
56
  droneLabel?: string;
55
57
  roleName?: string;
@@ -149,6 +151,8 @@ function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
149
151
  if (r.roleClass !== undefined && (typeof r.roleClass !== 'string' || !ROLE_CLASSES.has(r.roleClass))) return false;
150
152
  if (r.isHumanSeat !== undefined && typeof r.isHumanSeat !== 'boolean') return false;
151
153
  if (r.worktree !== undefined && typeof r.worktree !== 'string') return false;
154
+ if (r.commonDir !== undefined && !isNonEmptyString(r.commonDir)) return false;
155
+ if (r.repositoryOrigin !== undefined && !isNonEmptyString(r.repositoryOrigin)) return false;
152
156
  if (r.droneId !== undefined && (typeof r.droneId !== 'string' || !UUID_RE.test(r.droneId))) return false;
153
157
  if (r.sessionId !== undefined && (typeof r.sessionId !== 'string' || !UUID_RE.test(r.sessionId))) return false;
154
158
  // State-consistency invariants (no inconsistent active|pending).
@@ -447,6 +451,8 @@ export type ActivateSeatOutcome = 'activated' | 'missing' | 'replaced';
447
451
  * worktree is decided). Merged atomically with activation by activateAndBindSeat. */
448
452
  export interface SeatBinding {
449
453
  worktree: string;
454
+ commonDir?: string;
455
+ repositoryOrigin?: string;
450
456
  name: string;
451
457
  droneLabel: string;
452
458
  roleName?: string;
@@ -475,6 +481,8 @@ export async function activateAndBindSeat(input: {
475
481
  sessionId: string;
476
482
  expectedPendingDigest: string;
477
483
  worktree: string;
484
+ commonDir?: string;
485
+ repositoryOrigin?: string;
478
486
  name: string;
479
487
  droneLabel: string;
480
488
  roleName?: string;
@@ -496,6 +504,8 @@ export async function activateAndBindSeat(input: {
496
504
  droneId: input.droneId,
497
505
  sessionId: input.sessionId,
498
506
  worktree: input.worktree,
507
+ ...(input.commonDir !== undefined ? { commonDir: input.commonDir } : {}),
508
+ ...(input.repositoryOrigin !== undefined ? { repositoryOrigin: input.repositoryOrigin } : {}),
499
509
  name: input.name,
500
510
  droneLabel: input.droneLabel,
501
511
  ...(input.roleName !== undefined ? { roleName: input.roleName } : {}),
@@ -675,6 +685,23 @@ export async function readAllActiveSeats(): Promise<Array<{ worktree: string; re
675
685
  return out;
676
686
  }
677
687
 
688
+ /** Whether this cube has an active seat on the SAME repository from a different
689
+ * clone family. Repositories without a remote have no identity shared across
690
+ * clones, so their separate clones are undetectable here — see client#264. */
691
+ export async function hasActiveSeatInDifferentCloneFamily(
692
+ cubeId: string,
693
+ repositoryOrigin: string,
694
+ commonDir: string,
695
+ ): Promise<boolean> {
696
+ const seats = await readAllActiveSeats();
697
+ return seats.some(({ record }) =>
698
+ record.cubeId === cubeId &&
699
+ record.repositoryOrigin === repositoryOrigin &&
700
+ record.commonDir !== undefined &&
701
+ record.commonDir !== commonDir
702
+ );
703
+ }
704
+
678
705
  /** All valid worktree-bound registry entries, including a PENDING seat whose
679
706
  * interrupted finalize preserved its worktree for a later resume. Read-only:
680
707
  * pending records remain non-hydratable and getActiveSeatForWorktree stays
@@ -410,6 +410,8 @@ export async function sendBorgServerAttach(
410
410
  sessionId: decoded.session.id,
411
411
  expectedPendingDigest: pendingBearerDigest,
412
412
  worktree: binding.worktree,
413
+ ...(binding.commonDir !== undefined ? { commonDir: binding.commonDir } : {}),
414
+ ...(binding.repositoryOrigin !== undefined ? { repositoryOrigin: binding.repositoryOrigin } : {}),
413
415
  name: binding.name,
414
416
  droneLabel: binding.droneLabel,
415
417
  ...(binding.roleName !== undefined ? { roleName: binding.roleName } : {}),
@@ -49,7 +49,9 @@ export function openCodeWakePathHealthy(
49
49
  ) {
50
50
  return null;
51
51
  }
52
- if (state.sessionId === null) return null;
52
+ if (state.sessionId === null) {
53
+ return state.lastFailureCode === null ? null : false;
54
+ }
53
55
  return true;
54
56
  }
55
57