borgmcp 5.0.3 → 5.1.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.
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +4 -1
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/cubes.d.ts +2 -0
- package/dist/cubes.d.ts.map +1 -1
- package/dist/cubes.js +1 -0
- package/dist/cubes.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -8
- package/dist/index.js.map +1 -1
- package/dist/regen-format.d.ts +4 -2
- package/dist/regen-format.d.ts.map +1 -1
- package/dist/regen-format.js +40 -17
- package/dist/regen-format.js.map +1 -1
- package/dist/reset-local-seat-cmd.d.ts +3 -2
- package/dist/reset-local-seat-cmd.d.ts.map +1 -1
- package/dist/reset-local-seat-cmd.js +3 -2
- package/dist/reset-local-seat-cmd.js.map +1 -1
- package/dist/seats.d.ts +9 -4
- package/dist/seats.d.ts.map +1 -1
- package/dist/seats.js +71 -16
- package/dist/seats.js.map +1 -1
- package/docs/SEAT_LIFECYCLE.md +24 -15
- package/package.json +1 -1
- package/src/assimilate-cmd.ts +4 -1
- package/src/cubes.ts +6 -2
- package/src/index.ts +21 -8
- package/src/regen-format.ts +43 -18
- package/src/reset-local-seat-cmd.ts +3 -2
- package/src/seats.ts +76 -14
package/src/regen-format.ts
CHANGED
|
@@ -23,6 +23,8 @@ import { OPENCODE_WAKE_PATH_GUIDANCE } from './opencode-wake-copy.js';
|
|
|
23
23
|
import { isBorgSession } from './launch-gate.js';
|
|
24
24
|
import type { AgentKind } from './agent-runtime.js';
|
|
25
25
|
|
|
26
|
+
export type HandoverMode = 'origin' | 'local';
|
|
27
|
+
|
|
26
28
|
/**
|
|
27
29
|
* Extract the SessionStart `source` from a Claude Code hook payload (gh#926).
|
|
28
30
|
*
|
|
@@ -245,10 +247,16 @@ export function markArrivalAnnouncedThisProcess(): void {
|
|
|
245
247
|
// The playbook below points drones to `borg_docs {topic}` for user questions
|
|
246
248
|
// about how Borg MCP works; this marker pins the param so the #490/#529 guard
|
|
247
249
|
// verifies borg_docs exposes `topic`.
|
|
248
|
-
export function getDronePlaybook(): string {
|
|
250
|
+
export function getDronePlaybook(handoverMode: HandoverMode = 'origin'): string {
|
|
249
251
|
const arrivalInstruction = arrivalAnnouncedThisProcess
|
|
250
252
|
? ''
|
|
251
253
|
: `\n**Required one-time startup setup:** If this instruction is visible, immediately call \`borg_log message="ARRIVAL: <your-label> (<your-role>) online on ${osHostname()}" to="broadcast"\` before unread-log triage. This is the sole exception to no-work log silence. Do not infer your startup state from peers' \`ARRIVAL:\` entries. After the post succeeds, the client suppresses this instruction until the MCP process restarts; an explicit \`/mcp\` reconnect may show it again.\n`;
|
|
254
|
+
const reviewReadyRefs = handoverMode === 'local'
|
|
255
|
+
? '["HEAD"]'
|
|
256
|
+
: '["HEAD","origin/<branch>","origin/main"]';
|
|
257
|
+
const stagedDiffPurpose = handoverMode === 'local'
|
|
258
|
+
? 'Catches deleted files / anomalous -LOC / wrong paths before handover.'
|
|
259
|
+
: 'Catches deleted files / anomalous -LOC / wrong paths pre-push.';
|
|
252
260
|
return `## How to operate as a Drone
|
|
253
261
|
|
|
254
262
|
You're a Drone in a Cube. Coordinate with other drones through the activity log.
|
|
@@ -262,14 +270,14 @@ You're a Drone in a Cube. Coordinate with other drones through the activity log.
|
|
|
262
270
|
- \`borg_roster\` — see who else is connected
|
|
263
271
|
- \`borg_read-log unread_only=true [limit]\` — drain unread log entries from your server-side cursor
|
|
264
272
|
- \`borg_read-entry entry_id=<id>\` — read one known complete entry without moving the unread cursor
|
|
265
|
-
- \`borg_log message="<message>" to="broadcast"|["<selector>"] refs
|
|
273
|
+
- \`borg_log message="<message>" to="broadcast"|["<selector>"] refs=${reviewReadyRefs}\` — append with an explicit audience and optional mechanically resolved Git provenance; REVIEW-READY requires \`refs\`
|
|
266
274
|
- \`borg_assimilate <cube>\` — switch to a different cube
|
|
267
275
|
|
|
268
276
|
**How coordination works:** the Cube gives primitives, not workflows. Your role's \`detailed_description\` (above) is your playbook — its conventions + signals come from there, not the system. The log is the coordination channel. Different cubes, different conventions. Every \`borg_log\` call must choose its audience with \`to: "broadcast"\` or a non-empty selector array; omission, message text, prefixes, and classes never choose recipients.
|
|
269
277
|
|
|
270
278
|
**Communication discipline for non-human seats:**
|
|
271
279
|
- **Console:** write nothing except harness-required output. Surface something to the operator only when blocked and needing unblocking; do not narrate plans, progress, method, or results.
|
|
272
|
-
- **Log:** a post must change what another seat does. Otherwise, do not write it. Keep posts short: lifecycle signal + mechanically resolved SHA and nothing else; defect + location/evidence; correction to your live claim; or a genuine blocking question. Post REVIEW-READY with \`refs:
|
|
280
|
+
- **Log:** a post must change what another seat does. Otherwise, do not write it. Keep posts short: lifecycle signal + mechanically resolved SHA and nothing else; defect + location/evidence; correction to your live claim; or a genuine blocking question. Post REVIEW-READY with \`refs: ${reviewReadyRefs}\`.
|
|
273
281
|
- **Do not post:** plans, work-in-progress/progress narration, method or reasoning, restatements/agreement/credit, self-examination, framing phrases, or coordination commentary.
|
|
274
282
|
- **Evidence boundary:** state what a verdict did not exercise and any unavailable control in the same short clause. The human seat is excluded so its dispatches can explain constraints without being misapplied.
|
|
275
283
|
|
|
@@ -313,7 +321,7 @@ ${arrivalInstruction}
|
|
|
313
321
|
One seat uses one stable worktree. Start each new item there with \`git checkout -b <branch>\`; do not create another worktree for the item.
|
|
314
322
|
A reviewing seat uses \`git checkout --detach <SHA>\` in its own worktree. Keep scratch work under \`~/.borg/scratch/<seat>/\`; load \`borg_playbook\` for the full mechanism.
|
|
315
323
|
|
|
316
|
-
Any drone that commits code: run \`git diff --staged --stat\` before \`git commit\` to verify file count + LOC direction + paths match your intent.
|
|
324
|
+
Any drone that commits code: run \`git diff --staged --stat\` before \`git commit\` to verify file count + LOC direction + paths match your intent. ${stagedDiffPurpose} Your role may layer more git rules (code-implementing + coordinating roles typically carry the full set).`;
|
|
317
325
|
}
|
|
318
326
|
|
|
319
327
|
/**
|
|
@@ -329,7 +337,31 @@ Any drone that commits code: run \`git diff --staged --stat\` before \`git commi
|
|
|
329
337
|
// `borg_decisions {topic}`; this marker pins the param so the #490/#529
|
|
330
338
|
// copy-guard verifies borg_decisions exposes `topic`
|
|
331
339
|
// (client/__tests__/copy-mechanism-guard.test.ts).
|
|
332
|
-
export function getDronePlaybookChapter(): string {
|
|
340
|
+
export function getDronePlaybookChapter(handoverMode: HandoverMode = 'origin'): string {
|
|
341
|
+
const codeStateRef = handoverMode === 'local'
|
|
342
|
+
? 'a named local branch or commit'
|
|
343
|
+
: '`origin/main`, PR head, branch, merge-SHA, or tag';
|
|
344
|
+
const proposalRef = handoverMode === 'local'
|
|
345
|
+
? 'If the proposal cites a named local branch or commit, grep that ref via `git show <ref>:<path> | grep`'
|
|
346
|
+
: 'If the proposal cites current `origin/main` or a branch/SHA, grep that ref via `git show <ref>:<path> | grep`';
|
|
347
|
+
const commentRef = handoverMode === 'local'
|
|
348
|
+
? 'If the comment describes a named local branch or commit, grep that ref via `git show <ref>:<path> | grep`'
|
|
349
|
+
: 'If the comment describes a merged/base/PR-head state, grep the named ref via `git show <ref>:<path> | grep`';
|
|
350
|
+
const worktreeMechanism = handoverMode === 'local'
|
|
351
|
+
? `- One seat uses one stable worktree, created once at assimilation under the standard worktree root and approved once by the operator. All seats for a repository use worktrees from the same clone family, sharing its object database and refs.
|
|
352
|
+
- Start each new work item in that stable worktree with \`git checkout -b <branch>\`. Do not create a new worktree or folder for each item.
|
|
353
|
+
- A commit on a named branch is the handover artifact. Post REVIEW-READY through \`borg_log\` with \`refs: ["HEAD"]\`; a local branch ref is optional. The reviewing seat runs \`git checkout --detach <SHA>\` in its own worktree and never reads another seat's folder.
|
|
354
|
+
- Treat branch history as shared: another seat may have the branch checked out, so never rewrite it.
|
|
355
|
+
- A commit is the only checkpoint; repository backup is the operator's concern.
|
|
356
|
+
- Put detached review checkouts, clean-environment rigs, fake HOMEs, unpacked artifacts, and throwaway worktrees under \`~/.borg/scratch/<your-seat-label>/\`. Never use \`/tmp\` or an ad-hoc path. Scratch contents are disposable and must be cleaned up with the work.`
|
|
357
|
+
: `- One seat uses one stable worktree, created once at assimilation under the standard worktree root and approved once by the operator. All seats for a repository use worktrees from the same clone family, sharing its object database and refs.
|
|
358
|
+
- Start each new work item in that stable worktree with \`git checkout -b <branch>\`. Do not create a new worktree or folder for each item.
|
|
359
|
+
- Hand a branch to another seat only through an explicit log event.
|
|
360
|
+
- Treat branch history as shared: another seat may have the branch checked out or fetched, so never rebase or force-push it.
|
|
361
|
+
- Hand over refs and exact commit SHAs, never a filesystem path. Post REVIEW-READY through \`borg_log\` with \`refs: ["HEAD","origin/<branch>","origin/main"]\`; a reviewing seat checks out the resolved SHA in its own worktree with \`git checkout --detach <SHA>\` and never reads another seat's folder.
|
|
362
|
+
- With no hosted remote, the commit is the durable handover artifact because clone-family worktrees share refs; omit the push step. If local push/fetch semantics are required, use a local bare repository as the origin path.
|
|
363
|
+
- Put detached review checkouts, clean-environment rigs, fake HOMEs, unpacked artifacts, and throwaway worktrees under \`~/.borg/scratch/<your-seat-label>/\`. Never use \`/tmp\` or an ad-hoc path. Scratch contents are disposable and must be cleaned up with the work.
|
|
364
|
+
- When an origin exists, synchronize with merge-only history using \`git fetch origin && git merge origin/main\`.`;
|
|
333
365
|
return `## Operating playbook — full disciplines (borg_playbook chapter)
|
|
334
366
|
|
|
335
367
|
This is the on-demand detail behind the rule-spine in your regen. Load it ONCE per session; it is static — do not re-fetch on every wake.
|
|
@@ -346,7 +378,7 @@ Any time you make a factual claim that could be verified — "this shipped as ve
|
|
|
346
378
|
- Version attribution → \`git tag --contains <sha>\` or \`git log --oneline <tag>\`
|
|
347
379
|
- Code state → match the grep surface to the claim surface:
|
|
348
380
|
- Local uncommitted claim → \`grep -n "<symbol>" <file>\` or direct file read in the working tree
|
|
349
|
-
-
|
|
381
|
+
- ${codeStateRef} claim → \`git show <ref>:<path>\` followed by a symbol search in the returned source
|
|
350
382
|
- Prod state → \`curl https://<endpoint>\` or \`wrangler tail --env production\`
|
|
351
383
|
- npm registry state → \`npm view <package>@<version>\` or \`npm view <package>@latest\`
|
|
352
384
|
- DB state → query through the existing \`db\` interface; never trust a doc claim about row counts / column values
|
|
@@ -359,8 +391,8 @@ Any time you make a factual claim that could be verified — "this shipped as ve
|
|
|
359
391
|
|
|
360
392
|
The discipline applies at FOUR surfaces. Catches at the surface closest to origin are cheapest; catches at later surfaces have already propagated through earlier consumers:
|
|
361
393
|
|
|
362
|
-
- **Surface 1 (brainstorm-proposal time)**: when a brainstorm contribution names specific code identifiers / API field names / enum values / column names / function signatures, the PROPOSING drone source-grep's the referenced file BEFORE composing the proposal.
|
|
363
|
-
- **Surface 2 (comment/JSDoc/docstring writing time)**: when an implementation comment cites cross-file invariants (other modules' thresholds, schema columns, enum values, semantic contracts), the WRITING drone source-grep's the referenced file BEFORE writing the comment.
|
|
394
|
+
- **Surface 1 (brainstorm-proposal time)**: when a brainstorm contribution names specific code identifiers / API field names / enum values / column names / function signatures, the PROPOSING drone source-grep's the referenced file BEFORE composing the proposal. ${proposalRef}; working-tree grep is only for explicitly local/uncommitted claims. Cheapest catch surface; one drone catches one error.
|
|
395
|
+
- **Surface 2 (comment/JSDoc/docstring writing time)**: when an implementation comment cites cross-file invariants (other modules' thresholds, schema columns, enum values, semantic contracts), the WRITING drone source-grep's the referenced file BEFORE writing the comment. ${commentRef}; don't let a stale local checkout stand in for the ref being described. Mid-cost catch; one drone catches one error but downstream reviewers may inherit the wrong mental model from the comment.
|
|
364
396
|
- **Surface 3 (review-time verification)**: the existing review-class discipline (Code Reviewer formal gates + Security Auditor SR gates + PM/UX/QA courtesy reviews). Late catch opportunity; if the error propagated through Surfaces 1 + 2, multiple reviewers may have already trusted the framing instead of source-grepping themselves.
|
|
365
397
|
- **Surface 4 (durable-tracking-artifact-writing time)**: when filing a deferred-tracking issue from a cube event payload, the FILING drone fetches the originating entry's full body with \`borg_read-entry entry_id=<id>\` BEFORE composing the issue body. For routine wake triage, use \`borg_read-log unread_only=true\` and drain until caught up; do not rely on a truncated event preview or a \`since=<same timestamp>\` read, which can skip the boundary entry. Cube event previews can truncate substantive content (mid-paragraph cuts on long entries); filing from the truncated preview trusts a derivative artifact instead of the source-of-truth full entry. Most expensive surface — the filed issue becomes the cube's durable cross-cycle memory; correcting it requires a follow-up correction post, and later pickup drones inherit the incomplete framing if the correction is missed.
|
|
366
398
|
|
|
@@ -368,14 +400,7 @@ The discipline applies at FOUR surfaces. Catches at the surface closest to origi
|
|
|
368
400
|
|
|
369
401
|
**Worktree and git mechanism:**
|
|
370
402
|
|
|
371
|
-
|
|
372
|
-
- Start each new work item in that stable worktree with \`git checkout -b <branch>\`. Do not create a new worktree or folder for each item.
|
|
373
|
-
- Hand a branch to another seat only through an explicit log event.
|
|
374
|
-
- Treat branch history as shared: another seat may have the branch checked out or fetched, so never rebase or force-push it.
|
|
375
|
-
- Hand over refs and exact commit SHAs, never a filesystem path. Post REVIEW-READY through \`borg_log\` with \`refs: ["HEAD","origin/<branch>","origin/main"]\`; a reviewing seat checks out the resolved SHA in its own worktree with \`git checkout --detach <SHA>\` and never reads another seat's folder.
|
|
376
|
-
- With no hosted remote, the commit is the durable handover artifact because clone-family worktrees share refs; omit the push step. If local push/fetch semantics are required, use a local bare repository as the origin path.
|
|
377
|
-
- Put detached review checkouts, clean-environment rigs, fake HOMEs, unpacked artifacts, and throwaway worktrees under \`~/.borg/scratch/<your-seat-label>/\`. Never use \`/tmp\` or an ad-hoc path. Scratch contents are disposable and must be cleaned up with the work.
|
|
378
|
-
- When an origin exists, synchronize with merge-only history using \`git fetch origin && git merge origin/main\`.`;
|
|
403
|
+
${worktreeMechanism}`;
|
|
379
404
|
}
|
|
380
405
|
|
|
381
406
|
/**
|
|
@@ -531,7 +556,7 @@ export function formatRegenMarkdown(
|
|
|
531
556
|
// mid-session restatement moments. Absent on a pre-gh#740 worker → omitted.
|
|
532
557
|
decisions?: any[];
|
|
533
558
|
},
|
|
534
|
-
opts: { mode?: RegenMode } = {}
|
|
559
|
+
opts: { mode?: RegenMode; handoverMode?: HandoverMode } = {}
|
|
535
560
|
): string {
|
|
536
561
|
const mode = opts.mode ?? 'full';
|
|
537
562
|
const roleOverview = result.roles
|
|
@@ -676,7 +701,7 @@ export function formatRegenMarkdown(
|
|
|
676
701
|
);
|
|
677
702
|
|
|
678
703
|
if (shouldEmitPlaybook) {
|
|
679
|
-
lines.push('', getDronePlaybook());
|
|
704
|
+
lines.push('', getDronePlaybook(opts.handoverMode));
|
|
680
705
|
boilerplateEmittedThisSession = true;
|
|
681
706
|
}
|
|
682
707
|
if (shouldEmitRoleText && roleTextHash != null) {
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* seat reset from the ratified client-seat-reset-state-model (Option W).
|
|
4
4
|
*
|
|
5
5
|
* Attach is PURE DIAGNOSIS on a pin-matched SESSION_REJECTED (it mutates
|
|
6
|
-
* nothing and points here). This
|
|
7
|
-
*
|
|
6
|
+
* nothing and points here). This is the ONLY command that explicitly clears a
|
|
7
|
+
* worktree's saved local seat; a successful consented bind can also retire its
|
|
8
|
+
* predecessor atomically. The command performs ZERO network I/O: it never
|
|
8
9
|
* contacts the server, so it makes NO server-revocation claim. It clears ONLY
|
|
9
10
|
* this worktree's saved local seat — its credential and cube binding together —
|
|
10
11
|
* from the local seat store (keyed on findProjectRoot()); server, trust anchor,
|
package/src/seats.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface SeatRecord {
|
|
|
45
45
|
// credential
|
|
46
46
|
credential: string;
|
|
47
47
|
state: 'pending' | 'active';
|
|
48
|
+
/** Monotonic store-local order assigned when this record is bound active. */
|
|
49
|
+
bindingOrder?: number;
|
|
48
50
|
// server metadata (active only)
|
|
49
51
|
droneId?: string;
|
|
50
52
|
sessionId?: string;
|
|
@@ -144,6 +146,8 @@ function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
|
|
|
144
146
|
return false;
|
|
145
147
|
}
|
|
146
148
|
if (r.state !== 'pending' && r.state !== 'active') return false;
|
|
149
|
+
if (r.bindingOrder !== undefined &&
|
|
150
|
+
(typeof r.bindingOrder !== 'number' || !Number.isSafeInteger(r.bindingOrder) || r.bindingOrder < 1)) return false;
|
|
147
151
|
// Optional display/typed fields, validated when present.
|
|
148
152
|
if (r.name !== undefined && typeof r.name !== 'string') return false;
|
|
149
153
|
if (r.droneLabel !== undefined && typeof r.droneLabel !== 'string') return false;
|
|
@@ -169,7 +173,7 @@ function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
|
|
|
169
173
|
}
|
|
170
174
|
} else {
|
|
171
175
|
// A PENDING record must NOT carry active-only server session fields.
|
|
172
|
-
if (r.sessionId !== undefined) return false;
|
|
176
|
+
if (r.sessionId !== undefined || r.bindingOrder !== undefined) return false;
|
|
173
177
|
}
|
|
174
178
|
// The map key must equal the record's derived ref (no cross-key aliasing).
|
|
175
179
|
return seatRef(value as SeatRecord) === ref;
|
|
@@ -231,12 +235,56 @@ function parseStore(raw: string): SeatsFile | null {
|
|
|
231
235
|
}
|
|
232
236
|
if (!isValidSeatRecord(ref, record)) return null;
|
|
233
237
|
}
|
|
234
|
-
|
|
238
|
+
const store = { version: SEATS_VERSION, seats: seats as Record<string, SeatRecord> };
|
|
239
|
+
repairOrderedDuplicateBindings(store);
|
|
240
|
+
return store;
|
|
235
241
|
}
|
|
236
242
|
}
|
|
237
243
|
return null;
|
|
238
244
|
}
|
|
239
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Remove superseded worktree bindings when persisted ordering proves which bind
|
|
248
|
+
* is newest. The mutation is persisted by the next transaction that commits.
|
|
249
|
+
* Legacy duplicates without a complete, unique order stay intact and fail
|
|
250
|
+
* closed when that worktree is resolved.
|
|
251
|
+
*/
|
|
252
|
+
function repairOrderedDuplicateBindings(store: SeatsFile): void {
|
|
253
|
+
const byWorktree = new Map<string, Array<[string, SeatRecord]>>();
|
|
254
|
+
for (const entry of Object.entries(store.seats)) {
|
|
255
|
+
const [, record] = entry;
|
|
256
|
+
if (record.state !== 'active' || record.worktree === undefined) continue;
|
|
257
|
+
const candidates = byWorktree.get(record.worktree) ?? [];
|
|
258
|
+
candidates.push(entry);
|
|
259
|
+
byWorktree.set(record.worktree, candidates);
|
|
260
|
+
}
|
|
261
|
+
for (const candidates of byWorktree.values()) {
|
|
262
|
+
if (candidates.length < 2) continue;
|
|
263
|
+
const ordered = candidates.filter(
|
|
264
|
+
(entry): entry is [string, SeatRecord & { bindingOrder: number }] =>
|
|
265
|
+
entry[1].bindingOrder !== undefined,
|
|
266
|
+
);
|
|
267
|
+
if (ordered.length === 0) continue;
|
|
268
|
+
const orders = ordered.map(([, record]) => record.bindingOrder);
|
|
269
|
+
if (new Set(orders).size !== orders.length) continue;
|
|
270
|
+
const newest = ordered.reduce((left, right) =>
|
|
271
|
+
left[1].bindingOrder > right[1].bindingOrder ? left : right);
|
|
272
|
+
for (const [ref] of candidates) {
|
|
273
|
+
if (ref !== newest[0]) delete store.seats[ref];
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export class AmbiguousSeatBindingError extends Error {
|
|
279
|
+
constructor(worktree: string) {
|
|
280
|
+
super(
|
|
281
|
+
`Borg found multiple active seats for worktree ${worktree}, but their bind order is unavailable. ` +
|
|
282
|
+
'No identity or inbox path was selected. Run `borg reset-local-connection` to clear the ambiguous local connection.',
|
|
283
|
+
);
|
|
284
|
+
this.name = 'AmbiguousSeatBindingError';
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
240
288
|
async function readStore(): Promise<SeatsFile> {
|
|
241
289
|
const raw = await readStoreFile(SEATS_FILE);
|
|
242
290
|
// CR4 fail-closed: ENOENT alone initializes empty. A present-but-malformed /
|
|
@@ -498,9 +546,28 @@ export async function activateAndBindSeat(input: {
|
|
|
498
546
|
const record = txn.data.seats[ref];
|
|
499
547
|
if (!recordMatches(record, ref, binding)) return 'missing';
|
|
500
548
|
if (digestOf(record.credential) !== input.expectedPendingDigest) return 'replaced';
|
|
549
|
+
const highestOrder = Object.values(txn.data.seats).reduce(
|
|
550
|
+
(highest, candidate) => Math.max(highest, candidate.bindingOrder ?? 0),
|
|
551
|
+
0,
|
|
552
|
+
);
|
|
553
|
+
const bindingOrder = record.state === 'active' && record.worktree === input.worktree &&
|
|
554
|
+
record.bindingOrder !== undefined
|
|
555
|
+
? record.bindingOrder
|
|
556
|
+
: highestOrder + 1;
|
|
557
|
+
if (!Number.isSafeInteger(bindingOrder)) {
|
|
558
|
+
throw new Error('Borg private seat store bind order is exhausted; refusing to activate a seat');
|
|
559
|
+
}
|
|
560
|
+
const retiredRefs: string[] = [];
|
|
561
|
+
for (const [candidateRef, candidate] of Object.entries(txn.data.seats)) {
|
|
562
|
+
if (candidateRef !== ref && candidate.state === 'active' && candidate.worktree === input.worktree) {
|
|
563
|
+
delete txn.data.seats[candidateRef];
|
|
564
|
+
retiredRefs.push(candidateRef);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
501
567
|
txn.data.seats[ref] = {
|
|
502
568
|
...record,
|
|
503
569
|
state: 'active',
|
|
570
|
+
bindingOrder,
|
|
504
571
|
droneId: input.droneId,
|
|
505
572
|
sessionId: input.sessionId,
|
|
506
573
|
worktree: input.worktree,
|
|
@@ -513,6 +580,7 @@ export async function activateAndBindSeat(input: {
|
|
|
513
580
|
...(input.isHumanSeat !== undefined ? { isHumanSeat: input.isHumanSeat } : {}),
|
|
514
581
|
};
|
|
515
582
|
await txn.commit();
|
|
583
|
+
for (const retiredRef of retiredRefs) rejectedSeatRefs.delete(retiredRef);
|
|
516
584
|
rejectedSeatRefs.delete(ref);
|
|
517
585
|
return 'activated';
|
|
518
586
|
});
|
|
@@ -584,25 +652,19 @@ export async function bindPendingSeatToWorktree(input: {
|
|
|
584
652
|
// ─── Hydration / enumeration (scan by worktree) ──────────────────────────────
|
|
585
653
|
|
|
586
654
|
/** The preferred ACTIVE seat bound to `worktree`, or null. A pending record (no
|
|
587
|
-
* worktree, or non-active) is NEVER surfaced as a live binding.
|
|
588
|
-
*
|
|
589
|
-
*
|
|
655
|
+
* worktree, or non-active) is NEVER surfaced as a live binding. Persisted bind
|
|
656
|
+
* order is authoritative across processes; an unorderable legacy duplicate
|
|
657
|
+
* fails closed rather than selecting an identity or inbox path. */
|
|
590
658
|
export async function getActiveSeatForWorktree(worktree: string): Promise<SeatRecord | null> {
|
|
591
659
|
const store = await readStore();
|
|
592
660
|
const candidates = Object.entries(store.seats)
|
|
593
661
|
.filter(([ref, record]) =>
|
|
594
|
-
record.state === 'active' && record.worktree === worktree && seatRef(record) === ref)
|
|
595
|
-
|
|
596
|
-
const rejectionOrder = Number(rejectedSeatRefs.has(leftRef)) - Number(rejectedSeatRefs.has(rightRef));
|
|
597
|
-
if (rejectionOrder !== 0) return rejectionOrder;
|
|
598
|
-
const operationOrder = Number(left.operation.kind === 'seat') - Number(right.operation.kind === 'seat');
|
|
599
|
-
if (operationOrder !== 0) return operationOrder;
|
|
600
|
-
return leftRef < rightRef ? -1 : leftRef > rightRef ? 1 : 0;
|
|
601
|
-
});
|
|
662
|
+
record.state === 'active' && record.worktree === worktree && seatRef(record) === ref);
|
|
663
|
+
if (candidates.length > 1) throw new AmbiguousSeatBindingError(worktree);
|
|
602
664
|
return candidates[0]?.[1] ?? null;
|
|
603
665
|
}
|
|
604
666
|
|
|
605
|
-
/**
|
|
667
|
+
/** Remember a definitive server auth or eviction verdict in this process only. */
|
|
606
668
|
export function markSeatRejected(ref: string): void {
|
|
607
669
|
if (REF_RE.test(ref)) rejectedSeatRefs.add(ref);
|
|
608
670
|
}
|