hivecode-mcp 0.14.2 → 0.14.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.
- package/docs/TROUBLESHOOTING.md +1 -0
- package/hive-branch.js +68 -2
- package/hive-land.js +15 -1
- package/hive-mcp.js +59 -6
- package/hive-mux.js +4 -1
- package/hive-pr.js +42 -5
- package/hive-room-mode.js +130 -37
- package/hive-room-watcher.js +65 -4
- package/hive-slack.js +35 -2
- package/hive-transport.js +46 -7
- package/icr.js +7 -2
- package/lang-js.js +54 -10
- package/package.json +11 -3
- package/sync.js +265 -49
package/sync.js
CHANGED
|
@@ -753,6 +753,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
753
753
|
})
|
|
754
754
|
} catch { /* provenance must never break the sync path */ }
|
|
755
755
|
}
|
|
756
|
+
// NOTHING THIS CLIENT PUBLISHES ENTERS THE ROOM UNSIGNED (0.14.3 P0-3). With the fork gate on, a
|
|
757
|
+
// local edit is signed by captureLocal, which needs a baseline (`shadow`) to diff against. An edit
|
|
758
|
+
// made before the file first synced on a fresh join has none, and the rejoin and creation-race
|
|
759
|
+
// merges publish without passing through it at all. The content still landed, so the file's chain
|
|
760
|
+
// broke: `pr` then refused with a false Q506 ("the branch holds content this room has never seen")
|
|
761
|
+
// on a branch nobody had touched, and credited the file to the last agent that HAD signed (8-9 Nov
|
|
762
|
+
// mock run, test.js in demo-3). So after any local publish: if no receipt yields exactly what was
|
|
763
|
+
// published, sign it now, from the room's copy it replaced -- the state the room provably held.
|
|
764
|
+
// Only when THIS client's own edit went out unsigned: if captureLocal signed the authored text (or
|
|
765
|
+
// anyone signed what was published), nothing is added, so the fork gate sees exactly what it saw
|
|
766
|
+
// before. A receipt for a merged result is never added beside a signed edit: detectFork stops at a
|
|
767
|
+
// current text that has a receipt, and that must not hide a real fork.
|
|
768
|
+
function signPublished(relPath, roomBefore, published, authored = published) {
|
|
769
|
+
if (!PROVENANCE || !identity || published == null || published === roomBefore) return
|
|
770
|
+
const fe = fileDocs.get(relPath); if (!fe || !fe.ledger) return
|
|
771
|
+
const hp = contentHash(published), ha = authored == null ? null : contentHash(authored)
|
|
772
|
+
const rc = fe.ledger.toArray()
|
|
773
|
+
if (rc.some((r) => r && (r.contentHash === hp || (ha && r.author === identity.id && r.contentHash === ha)))) return
|
|
774
|
+
stampIntent(relPath, roomBefore == null ? '' : roomBefore, published)
|
|
775
|
+
}
|
|
756
776
|
function attestHead(relPath, landed, base) {
|
|
757
777
|
if (!PROVENANCE || !identity || landed == null) return
|
|
758
778
|
const fe = fileDocs.get(relPath); if (!fe || !fe.head) return
|
|
@@ -1052,6 +1072,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1052
1072
|
if (!canWrite(relPath)) return { error: `${relPath} is read-only for you` }
|
|
1053
1073
|
if (intent) myIntents.set(relPath, String(intent)) // my declared WHY — what my receipt will sign
|
|
1054
1074
|
const full = path.join(ROOT, relPath)
|
|
1075
|
+
// Written before this file synced: remember what it replaced, the edit's real ancestor (P0-1b).
|
|
1076
|
+
// The OLDEST text this folder had since the file started waiting (diskAtOpen) when there is one: an
|
|
1077
|
+
// earlier edit with the agent's own file tool sits between it and this write, and the room can only
|
|
1078
|
+
// have held the older text (0.14.3 P0-1b-R3).
|
|
1079
|
+
{ const fe0 = fileDocs.get(relPath); if (!(fe0 && fe0.synced) && !preSyncBase.has(relPath)) { try { preSyncBase.set(relPath, fe0 && typeof fe0.diskAtOpen === 'string' ? fe0.diskAtOpen : readDiskText(full)) } catch { } } }
|
|
1055
1080
|
// An authored write keeps this disk's ending for the file, like every engine write (readText).
|
|
1056
1081
|
try { fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(full, crlfSet().has(relPath) ? toLF(content).replace(/\n/g, '\r\n') : content) }
|
|
1057
1082
|
catch (e) { return { error: 'write failed: ' + e.message } }
|
|
@@ -1061,7 +1086,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1061
1086
|
if (FORK_GATE) captureLocal(relPath)
|
|
1062
1087
|
reconcile(relPath, 'local')
|
|
1063
1088
|
const fe = fileDocs.get(relPath)
|
|
1064
|
-
|
|
1089
|
+
// The room's copy is known only once the file synced; before that, say nothing about it rather
|
|
1090
|
+
// than echo the content back as if it had landed (0.14.3 P0-1). hive_edit asks whenSent().
|
|
1091
|
+
const landed = fe && fe.synced ? fe.text.toString() : null
|
|
1065
1092
|
let disk = content; try { disk = readDiskText(full) } catch { }
|
|
1066
1093
|
// A new file's doc has not synced yet when this returns, so the gate in reconcile has not run:
|
|
1067
1094
|
// judge the content here too, or the answer would be "syncing to every peer" for a file that
|
|
@@ -1333,6 +1360,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1333
1360
|
// "not pulled yet", NOT "new file", so we must not re-seed it from disk (that
|
|
1334
1361
|
// would create a second CRDT history and duplicate/garble content on rejoin).
|
|
1335
1362
|
const fileDocs = new Map() // relPath -> { doc, provider, text, synced }
|
|
1363
|
+
// EDITS NOT YET ACCEPTED BY THE RELAY (0.14.3 P0-1). A file enters on any local change to its doc
|
|
1364
|
+
// and leaves once its channel is connected, synced and its socket holds no unsent bytes. Synced
|
|
1365
|
+
// means the sync exchange ran after the change (it carries anything the socket missed), so
|
|
1366
|
+
// "connected + synced + nothing buffered" is "the relay has it". Over HTTPS long-poll the bytes sit
|
|
1367
|
+
// in the client until the relay accepts them; an agent that left in that window lost the edit while
|
|
1368
|
+
// hive_edit said it was syncing (8-9 Nov mock run). leave() waits on this set, hive_edit reports it,
|
|
1369
|
+
// and eviction never closes a file that is still in it.
|
|
1370
|
+
const unsentLocal = new Set()
|
|
1336
1371
|
|
|
1337
1372
|
// REFUSED FILE-ROOMS. y-websocket retries a failed upgrade forever, with backoff and in
|
|
1338
1373
|
// silence. That is right for a flaky network and badly wrong for a relay that is full: on
|
|
@@ -1524,8 +1559,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1524
1559
|
// each peer's DISK — never into the shared text — so conflict markers can't fuse.
|
|
1525
1560
|
const conflict = fdoc.getMap('conflict')
|
|
1526
1561
|
const undo = new Y.UndoManager(text, { captureTimeout: 350 }) // default tracks local-origin txns
|
|
1527
|
-
|
|
1562
|
+
// diskAtOpen: this folder's copy when the file started waiting for the room. An edit made before
|
|
1563
|
+
// the file syncs -- through hive_edit OR the agent's own file tool -- was made on top of it, so it
|
|
1564
|
+
// is that edit's ancestor at first contact, if the room held it (0.14.3 P0-1b-R3). Dropped once synced.
|
|
1565
|
+
let diskAtOpen
|
|
1566
|
+
try { const f0 = path.join(ROOT, relPath); if (fs.statSync(f0).size <= MAX_BYTES) diskAtOpen = readDiskText(f0) } catch { diskAtOpen = undefined }
|
|
1567
|
+
e = { doc: fdoc, provider: fprovider, text, bin, snap, ledger, head, versions, forkmark, conflict, undo, synced: false, diskAtOpen }
|
|
1528
1568
|
fileDocs.set(relPath, e)
|
|
1569
|
+
// A local transaction is one this client made; a peer's arrives through applyUpdate (local false).
|
|
1570
|
+
fdoc.on('update', (_u, _origin, _doc, txn) => { if (!txn || txn.local) unsentLocal.add(relPath) })
|
|
1529
1571
|
// From the mtime scan() already took, not two more syscalls per open: a 1,000-file join opens
|
|
1530
1572
|
// 1,000 files, and fileCreationTs() still falls back to a stat for any file this missed.
|
|
1531
1573
|
if (!createdTimes.has(relPath) && mtimes.has(relPath)) createdTimes.set(relPath, mtimes.get(relPath))
|
|
@@ -1547,6 +1589,8 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1547
1589
|
reconcile(relPath, 'remote')
|
|
1548
1590
|
detectFork(relPath)
|
|
1549
1591
|
}
|
|
1592
|
+
preSyncBase.delete(relPath) // used by the first reconcile or not at all, never by a later reopen (review R4)
|
|
1593
|
+
e.diskAtOpen = undefined
|
|
1550
1594
|
// A new file that has just landed on disk frees its place for the next one (a burst of new
|
|
1551
1595
|
// files moves at sync speed, not at the 30 s grace), and lets waiting new files in.
|
|
1552
1596
|
if (arrivedNew.has(relPath)) {
|
|
@@ -1584,6 +1628,88 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1584
1628
|
scheduleEvictRecheck()
|
|
1585
1629
|
return e
|
|
1586
1630
|
}
|
|
1631
|
+
// `await await f()` THAT NOBODY WROTE (0.14.3 P0-5-3). Two peers making the same sync -> async change
|
|
1632
|
+
// at once each insert `await ` before the same call (each merge is right on its own), and the shared
|
|
1633
|
+
// text keeps both inserts. Remove the outer one, here, before the room's copy reaches the disk:
|
|
1634
|
+
// pure deletes of the spans lang.stackedAwaits finds (AST-level, whitespace-only between, so a
|
|
1635
|
+
// comment or a string that says "await await" is never touched). On the same converged text every
|
|
1636
|
+
// peer deletes the same characters, so two peers doing it at once remove them once. Never a
|
|
1637
|
+
// whole-text diff: that would delete and re-insert everything between two such spots, and two
|
|
1638
|
+
// peers re-inserting the same middle is the fusion this engine exists to prevent. Skipped when
|
|
1639
|
+
// this folder's own copy has one (its author wrote it) and for a file this member cannot write.
|
|
1640
|
+
function dropStackedAwaits(relPath, fe, disk) {
|
|
1641
|
+
try {
|
|
1642
|
+
if (!supports(relPath) || !canWrite(relPath)) return false
|
|
1643
|
+
const lang = languageFor(relPath)
|
|
1644
|
+
if (!lang || typeof lang.stackedAwaits !== 'function') return false
|
|
1645
|
+
const t = fe.text.toString()
|
|
1646
|
+
if (!/\bawait\s+await\b/.test(t)) return false
|
|
1647
|
+
if (typeof disk === 'string' && /\bawait\s+await\b/.test(disk) && lang.stackedAwaits(disk).length) return false
|
|
1648
|
+
const spans = lang.stackedAwaits(t)
|
|
1649
|
+
if (!spans.length) return false
|
|
1650
|
+
fe.doc.transact(() => { for (let i = spans.length - 1; i >= 0; i--) fe.text.delete(spans[i][0], spans[i][1] - spans[i][0]) })
|
|
1651
|
+
log(`[${name}] ${relPath}: removed ${spans.length} stacked \`await\` left by two peers making the same change at once`)
|
|
1652
|
+
return true
|
|
1653
|
+
} catch { return false }
|
|
1654
|
+
}
|
|
1655
|
+
/** Has the relay accepted every local change to this file? (See unsentLocal.) */
|
|
1656
|
+
function isSentNow(relPath) {
|
|
1657
|
+
if (!unsentLocal.has(relPath)) return true
|
|
1658
|
+
const fe = fileDocs.get(relPath)
|
|
1659
|
+
if (!fe) return false
|
|
1660
|
+
const p = fe.provider
|
|
1661
|
+
if (!(p && p.wsconnected && p.synced && !(p.bufferedAmount > 0))) return false
|
|
1662
|
+
// Delivered is not enough: the room must HOLD what this folder has. On first contact a local edit
|
|
1663
|
+
// the room cannot prove it has seen is kept here and deliberately NOT published (and a refused
|
|
1664
|
+
// fused merge publishes nothing); calling that "sent" told an agent its work was in the room when
|
|
1665
|
+
// the room still had the old copy. A deleted file, or one this side cannot read as text, is judged
|
|
1666
|
+
// on delivery alone.
|
|
1667
|
+
// A BINARY file's content is in fe.bin, not fe.text (which is "" for it): compared as text it never
|
|
1668
|
+
// matched, so a binary this member added was never "sent" -- leave() waited its full timeout and
|
|
1669
|
+
// named it as lost, and eviction never closed it (Opus-Cloud's review of 0.14.3). The room holds
|
|
1670
|
+
// what this folder has when the bytes' sha256 is the one the room records, as reconcileBinary writes it.
|
|
1671
|
+
if (fe.bin && fe.bin.has('data')) {
|
|
1672
|
+
let bytes = null
|
|
1673
|
+
try { bytes = fs.readFileSync(path.join(ROOT, relPath)) } catch { }
|
|
1674
|
+
if (bytes && crypto.createHash('sha256').update(bytes).digest('hex') !== fe.bin.get('sha')) return false
|
|
1675
|
+
unsentLocal.delete(relPath)
|
|
1676
|
+
return true
|
|
1677
|
+
}
|
|
1678
|
+
let disk = null
|
|
1679
|
+
try { disk = readDiskText(path.join(ROOT, relPath)) } catch { }
|
|
1680
|
+
if (disk != null && !hasConflictMarkers(disk) && toLF(disk) !== toLF(fe.text.toString())) return false
|
|
1681
|
+
unsentLocal.delete(relPath)
|
|
1682
|
+
return true
|
|
1683
|
+
}
|
|
1684
|
+
/** Resolves true once the relay has this file's local changes, false if not within timeoutMs. */
|
|
1685
|
+
async function whenSent(relPath, timeoutMs = 3000) {
|
|
1686
|
+
relPath = String(relPath || '').replace(/\\/g, '/')
|
|
1687
|
+
const end = Date.now() + Math.max(0, Number(timeoutMs) || 0)
|
|
1688
|
+
for (;;) {
|
|
1689
|
+
if (isSentNow(relPath)) return true
|
|
1690
|
+
if (stopped || Date.now() >= end) return false
|
|
1691
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
const roomBuffered = () => { try { return (provider.ws && provider.ws.bufferedAmount) || 0 } catch { return 0 } }
|
|
1695
|
+
// LEAVE WITHOUT LOSING WHAT WAS JUST WRITTEN. stop() tears everything down at once; leave() first
|
|
1696
|
+
// hands the pending work over (debounced disk edits, deletes, the room doc), waits until the relay
|
|
1697
|
+
// has accepted every local change -- files and chat/claims alike -- and only then stops. Whatever
|
|
1698
|
+
// did not make it within timeoutMs is NAMED in the answer instead of vanishing (0.14.3 P0-1).
|
|
1699
|
+
async function leave({ timeoutMs = 10000 } = {}) {
|
|
1700
|
+
if (stopped) return { unsent: [], roomUnsent: false }
|
|
1701
|
+
try { if (debounce) { clearTimeout(debounce); flushFsEvents() } } catch { }
|
|
1702
|
+
try { flushFirstContact() } catch { }
|
|
1703
|
+
try { settleLocalDeletes() } catch { }
|
|
1704
|
+
try { flushRoomDoc() } catch { }
|
|
1705
|
+
const end = Date.now() + Math.max(0, Number(timeoutMs) || 0)
|
|
1706
|
+
const pendingNow = () => [...unsentLocal].filter((r) => !isSentNow(r))
|
|
1707
|
+
while (Date.now() < end && (pendingNow().length || roomBuffered() > 0)) await new Promise((r) => setTimeout(r, 50))
|
|
1708
|
+
const unsent = pendingNow()
|
|
1709
|
+
const roomUnsent = roomBuffered() > 0
|
|
1710
|
+
stopAll()
|
|
1711
|
+
return { unsent, roomUnsent }
|
|
1712
|
+
}
|
|
1587
1713
|
function closeFile(relPath) {
|
|
1588
1714
|
const e = fileDocs.get(relPath)
|
|
1589
1715
|
if (!e) return
|
|
@@ -1675,6 +1801,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1675
1801
|
const cand = []
|
|
1676
1802
|
for (const [r, e] of fileDocs) {
|
|
1677
1803
|
if (r === keep || !e.synced) continue
|
|
1804
|
+
if (!isSentNow(r)) continue // closing it would drop an edit the relay has not accepted yet
|
|
1678
1805
|
if (conflicted.has(r)) continue
|
|
1679
1806
|
try { if (e.conflict && e.conflict.size) continue } catch { }
|
|
1680
1807
|
if (isClaimed(r)) continue
|
|
@@ -1705,6 +1832,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1705
1832
|
const cand = []
|
|
1706
1833
|
for (const [r, e] of fileDocs) {
|
|
1707
1834
|
if (conflicted.has(r) || isClaimed(r)) continue
|
|
1835
|
+
if (unsentLocal.has(r)) continue // a local edit the relay has not accepted: closing it loses work
|
|
1708
1836
|
try { if (e.conflict && e.conflict.size) continue } catch { }
|
|
1709
1837
|
cand.push([r, touched.get(r) || 0])
|
|
1710
1838
|
}
|
|
@@ -1877,6 +2005,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1877
2005
|
// merge. Keyed by room — the same folder rejoined against a different room must not
|
|
1878
2006
|
// inherit an ancestor from unrelated history.
|
|
1879
2007
|
const priorShadow = new Map()
|
|
2008
|
+
// relPath -> the disk text an authored write (editFile) replaced while the file had not synced yet:
|
|
2009
|
+
// that write's true ancestor, used by first contact (0.14.3 P0-1b).
|
|
2010
|
+
const preSyncBase = new Map()
|
|
1880
2011
|
const bases = new Map() // last synced content per file (advances on every reconcile)
|
|
1881
2012
|
const forkBases = new Map() // FORK POINT: what THIS author last saw/authored per file.
|
|
1882
2013
|
// forkBases advances only on local authorship / first adoption — NOT when a
|
|
@@ -2076,6 +2207,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2076
2207
|
const fold = (r) => isCaseInsensitive ? r.toLowerCase() : r
|
|
2077
2208
|
|
|
2078
2209
|
const canonicalCaseKeys = new Map() // fold(r) -> canonical relPath
|
|
2210
|
+
const recentlyLocallyRemovedCase = new Map() // fold(r) -> { path: r, at: Date.now() }
|
|
2079
2211
|
const collided = new Map() // collided relPath -> { with: canonical relPath }
|
|
2080
2212
|
const collidedWarned = new Set() // relPath announced to room
|
|
2081
2213
|
const collidedTimers = new Map() // relPath -> Timeout (debounced room announcement)
|
|
@@ -2555,8 +2687,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2555
2687
|
// (duplicated/garbled content on rejoin). So: open if needed, then defer until
|
|
2556
2688
|
// the 'sync' handler re-runs us. Once synced, "" genuinely means empty.
|
|
2557
2689
|
fe = fileDocs.get(relPath)
|
|
2558
|
-
|
|
2559
|
-
|
|
2690
|
+
// A local change waiting for its file to sync is an unsent edit too: the sync handler sends it,
|
|
2691
|
+
// and until then leave() must wait for it (0.14.3 P0-1: a new file written just before leaving).
|
|
2692
|
+
if (!fe) { if (disk === null) return; openFile(relPath); if (origin === 'local') unsentLocal.add(relPath); return }
|
|
2693
|
+
if (!fe.synced) { if (origin === 'local' && disk !== null) unsentLocal.add(relPath); return }
|
|
2560
2694
|
// A KEPT UNSENT EDIT IS FROZEN until its author saves the file again. Merging it against the
|
|
2561
2695
|
// room in either direction would decide something nobody decided: the room's next change would
|
|
2562
2696
|
// be written over it, and an unchanged save merged against itself pulled the room's old copy over
|
|
@@ -2632,6 +2766,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2632
2766
|
// clean again -> a resolution landed elsewhere; adopt it (order-independent w/ observer).
|
|
2633
2767
|
if (disk !== null && hasConflictMarkers(disk) && !hasConflictMarkers(fe.text.toString())) { adoptResolved(relPath); return }
|
|
2634
2768
|
}
|
|
2769
|
+
dropStackedAwaits(relPath, fe, disk) // before anything reads the room's copy (0.14.3 P0-5-3)
|
|
2635
2770
|
const docText = fe.text.toString()
|
|
2636
2771
|
// An empty shared doc is authoritative ONLY if we have history for this path.
|
|
2637
2772
|
// A file's NAME lives in the parent doc and its CONTENT in the file's own doc, on two
|
|
@@ -2675,6 +2810,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2675
2810
|
}
|
|
2676
2811
|
if (disk === docText) {
|
|
2677
2812
|
ensureExactDiskCasing(relPath)
|
|
2813
|
+
if (!inManifest(relPath) && origin === 'local') {
|
|
2814
|
+
const key = fold(relPath)
|
|
2815
|
+
const prevCase = recentlyLocallyRemovedCase.get(key)
|
|
2816
|
+
if (prevCase && (Date.now() - prevCase.at < 60000)) {
|
|
2817
|
+
recentlyLocallyRemovedCase.delete(key)
|
|
2818
|
+
queueManifestSet(relPath)
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2678
2821
|
// CREATING a file is editing it. This branch is where a locally-authored NEW file
|
|
2679
2822
|
// lands: scan() finds it, openFile() syncs the content up, and by the time reconcile
|
|
2680
2823
|
// runs disk and doc already agree -- so it returns below, before the markEditing() at
|
|
@@ -2806,7 +2949,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2806
2949
|
return
|
|
2807
2950
|
}
|
|
2808
2951
|
}
|
|
2809
|
-
if (r0.text !== docText) fe.doc.transact(() => applyDiff(yt, r0.text))
|
|
2952
|
+
if (r0.text !== docText) { fe.doc.transact(() => applyDiff(yt, r0.text)); signPublished(relPath, docText, r0.text, disk) }
|
|
2810
2953
|
if (r0.text !== disk) writeToDisk(relPath, r0.text)
|
|
2811
2954
|
if (r0.conflict) conflicted.add(relPath)
|
|
2812
2955
|
bases.set(relPath, r0.text); forkBases.set(relPath, r0.text); shadow.set(relPath, r0.text)
|
|
@@ -2819,6 +2962,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2819
2962
|
// local copy may be newer than the room's (edited while disconnected, or the room
|
|
2820
2963
|
// came back stale). This is the data-loss case that was seen live.
|
|
2821
2964
|
let anc = priorShadow.get(relPath)
|
|
2965
|
+
// An edit THIS session made through the medium before the file had synced has a known ancestor:
|
|
2966
|
+
// the disk text it replaced (0.14.3 P0-1b). Without it, a fresh folder's first contact had no
|
|
2967
|
+
// ancestor, refused to publish the edit, and a later merge wrote the room's copy back over it.
|
|
2968
|
+
// ONLY when the room held that text (Claude-cloud's review R1): a folder carrying a local change L
|
|
2969
|
+
// the room never saw, edited before sync, would otherwise merge with ancestor L, read the room as
|
|
2970
|
+
// "removed L", and delete L from disk and room. Not proven held -> no ancestor -> the frozen keep.
|
|
2971
|
+
let preSync = false
|
|
2972
|
+
// An edit made with the agent's OWN file tool before the file synced left no record (only hive_edit
|
|
2973
|
+
// writes one), so first contact had no ancestor and froze it as a kept, unsent edit (P0-1b-R3).
|
|
2974
|
+
// What the disk held when the file opened is that ancestor, under the same rule: only if the room
|
|
2975
|
+
// provably held it.
|
|
2976
|
+
if (anc === undefined && !preSyncBase.has(relPath) && typeof fe.diskAtOpen === 'string' && disk !== null && fe.diskAtOpen !== disk) {
|
|
2977
|
+
preSyncBase.set(relPath, fe.diskAtOpen)
|
|
2978
|
+
}
|
|
2979
|
+
if (anc === undefined && preSyncBase.has(relPath)) {
|
|
2980
|
+
const pre = preSyncBase.get(relPath)
|
|
2981
|
+
const held = accountFor({ rel: relPath, branchText: pre, roomText: docText, receipts: fe.ledger ? fe.ledger.toArray() : [] }).state
|
|
2982
|
+
if (held === DESCENDS || held === IDENTICAL) { anc = pre; preSync = true }
|
|
2983
|
+
}
|
|
2984
|
+
preSyncBase.delete(relPath)
|
|
2822
2985
|
// ...unless the room never got it. Capturing a local edit moves the ancestor to the edited
|
|
2823
2986
|
// bytes BEFORE the relay accepts them. A write held behind someone else's claim
|
|
2824
2987
|
// (HIVE_ENFORCE_CLAIMS=queue) is discarded when its writer disconnects, so on rejoin
|
|
@@ -2855,7 +3018,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2855
3018
|
// the local side first so the pre-merge bytes are recoverable regardless of outcome.
|
|
2856
3019
|
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy before rejoin merge' })
|
|
2857
3020
|
const rj = icrMerge3(anc, disk, docText, relPath)
|
|
2858
|
-
if (rj.text !== docText) fe.doc.transact(() => applyDiff(yt, rj.text))
|
|
3021
|
+
if (rj.text !== docText) { fe.doc.transact(() => applyDiff(yt, rj.text)); signPublished(relPath, docText, rj.text, disk) }
|
|
2859
3022
|
if (rj.text !== disk) writeToDisk(relPath, rj.text)
|
|
2860
3023
|
if (rj.conflict) conflicted.add(relPath)
|
|
2861
3024
|
bases.set(relPath, rj.text); forkBases.set(relPath, rj.text); shadow.set(relPath, rj.text)
|
|
@@ -2864,7 +3027,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2864
3027
|
say(unsent
|
|
2865
3028
|
? `⚠ ${relPath}: ${name}'s last edit never reached the room — it was held while someone else had the file, and ${name}'s connection dropped. ` +
|
|
2866
3029
|
`It has been sent again, merged with the room's copy${rj.conflict ? ', and CONFLICT markers need resolving' : ''}; if the file is still claimed, it waits for the release. Pre-merge local copy is in the file's history.`
|
|
2867
|
-
:
|
|
3030
|
+
: preSync
|
|
3031
|
+
? `${relPath}: ${name} edited it before the room's copy had loaded here — the edit was merged with the room's copy and sent${rj.conflict ? ', and CONFLICT markers need resolving' : ''}. Pre-merge local copy is in the file's history.`
|
|
3032
|
+
: `⚠ ${relPath} changed locally while ${name} was disconnected — the local edit and the room copy were merged${rj.conflict ? ', and CONFLICT markers need resolving' : ''}. Pre-merge local copy is in the file's history.`)
|
|
2868
3033
|
} catch { }
|
|
2869
3034
|
return
|
|
2870
3035
|
}
|
|
@@ -2891,7 +3056,22 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2891
3056
|
// Refuse adoption: keep local disk untouched, keep room doc untouched, snapshot local, and announce loudly.
|
|
2892
3057
|
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: `local copy preserved at join (${st.state}: room copy refused)` })
|
|
2893
3058
|
bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
2894
|
-
|
|
3059
|
+
// FROZEN, not merely kept (0.14.3 P0-1b). With bases = the local copy and nothing else, the NEXT
|
|
3060
|
+
// reconcile of this file (the watcher seeing the agent's own write, the scan, any remote change)
|
|
3061
|
+
// merged (base local, ours local, theirs room) = the room's copy, wrote it over the local edit
|
|
3062
|
+
// and told the room "kept the changes that landed in between (nothing lost)": the edit was gone
|
|
3063
|
+
// from the room AND the disk (5 of 8 poll runs on 0.14.2, tests/diag/presync-edit-race.mjs;
|
|
3064
|
+
// traced by Claude-cloud). Frozen like a kept unsent edit: nothing syncs for this file until its
|
|
3065
|
+
// author saves it again, and that save replaces the room's copy. The worst case is now "kept,
|
|
3066
|
+
// and said", never "lost".
|
|
3067
|
+
let keptAt = 0; try { keptAt = fs.statSync(full).mtimeMs } catch { }
|
|
3068
|
+
keptUnsent.set(relPath, keptAt)
|
|
3069
|
+
// Said in the room, not only logged (review R5): the agent has to act on it.
|
|
3070
|
+
try {
|
|
3071
|
+
say(`⚠ ${relPath}: ${name}'s copy differs from the room's, and the room cannot tell which is newer (${st.state}: ${name} changed it before the room's copy had loaded here). ` +
|
|
3072
|
+
`Neither was written over the other: ${name}'s is kept on disk, both are in the file's history, and nothing syncs for this file until ${name} saves it again. That save replaces the room's copy. Claim ${relPath} first.`)
|
|
3073
|
+
} catch { }
|
|
3074
|
+
log(`[${name}] ⚠ ${relPath} already existed in room with different content (${st.state}) — room copy was NOT adopted; local disk preserved and frozen until saved again`)
|
|
2895
3075
|
noteFirstContact('kept', relPath)
|
|
2896
3076
|
return
|
|
2897
3077
|
}
|
|
@@ -2982,7 +3162,13 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2982
3162
|
res = { text: disk, conflict: false } // author built on the latest or cleanly rewrote — trust it
|
|
2983
3163
|
} else {
|
|
2984
3164
|
res = icrMerge3(fork, disk, docText, relPath)
|
|
2985
|
-
reAdded = !res.conflict && res.text !== disk // we restored remote lines a stale write had dropped
|
|
3165
|
+
reAdded = !res.conflict && res.text !== disk && res.text !== docText // we restored remote lines a stale write had dropped
|
|
3166
|
+
// ...and when the merge gives back exactly the room's copy, the local edit did NOT survive:
|
|
3167
|
+
// never call that "nothing lost" (review R2). Keep the local copy and say so.
|
|
3168
|
+
if (!res.conflict && res.text === docText && disk !== docText) {
|
|
3169
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy replaced by the room\'s copy in a merge' })
|
|
3170
|
+
try { say(`⚠ ${relPath}: ${name}'s local change did not survive the merge with the room's copy (the room's version was taken). ${name}'s copy is in the file's history.`) } catch { }
|
|
3171
|
+
}
|
|
2986
3172
|
// OPEN (1 Nov 2026): a clean handover onto the same lines still shows markers. Four rounds of
|
|
2987
3173
|
// rules that decided it from the texts (a claim-time fork, who-holds-it, line counts, shared
|
|
2988
3174
|
// tokens) each silently deleted someone's work in a case antigravity-3 then built: from the
|
|
@@ -3055,7 +3241,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3055
3241
|
// Gate ON: the author's INTENT was already captured at the source (captureLocal, top
|
|
3056
3242
|
// of this pass) — here we only advance the authority head to the landed content.
|
|
3057
3243
|
// Gate OFF (default): the proven landed-transition stamp, unchanged.
|
|
3058
|
-
if (FORK_GATE) { if (res.text !== docText) attestHead(relPath, res.text, docText) }
|
|
3244
|
+
if (FORK_GATE) { if (res.text !== docText) { signPublished(relPath, docText, res.text, disk); attestHead(relPath, res.text, docText) } }
|
|
3059
3245
|
else if (res.text !== docText) stampProvenance(relPath, docText, res.text)
|
|
3060
3246
|
}
|
|
3061
3247
|
if (res.text !== disk) writeToDisk(relPath, res.text)
|
|
@@ -3758,20 +3944,30 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3758
3944
|
function checkBuilds() {
|
|
3759
3945
|
// An old engine cannot speak for itself, so SOMEBODY current must -- but only one: the reporting
|
|
3760
3946
|
// client whose name sorts first. Otherwise every current client in the room says the same thing.
|
|
3947
|
+
// Listeners (listener: true, syncFiles: false) do not sync or edit files and do not report builds.
|
|
3761
3948
|
const reporters = [name]
|
|
3762
|
-
|
|
3949
|
+
const currentEngines = new Set()
|
|
3950
|
+
for (const s of provider.awareness.getStates().values()) {
|
|
3951
|
+
const u = s && s.user
|
|
3952
|
+
if (!u || !u.name || u.v !== PROTOCOL_VERSION || u.listener) continue
|
|
3953
|
+
if (u.hv) reporters.push(u.name)
|
|
3954
|
+
if (u.hv === VERSION_LABEL) currentEngines.add(u.name)
|
|
3955
|
+
}
|
|
3763
3956
|
const speakForOld = reporters.sort()[0] === name
|
|
3957
|
+
|
|
3764
3958
|
for (const s of provider.awareness.getStates().values()) {
|
|
3765
3959
|
const u = s && s.user
|
|
3766
|
-
if (!u || !u.name || u.name === name || u.v !== PROTOCOL_VERSION) continue
|
|
3960
|
+
if (!u || !u.name || u.name === name || u.v !== PROTOCOL_VERSION || u.listener) continue
|
|
3767
3961
|
const human = String(u.kind || '').toLowerCase() === 'human'
|
|
3768
3962
|
let theirs = ''
|
|
3769
3963
|
if (u.hv) {
|
|
3770
3964
|
if (u.hv === VERSION_LABEL || name > u.name) continue
|
|
3771
|
-
|
|
3965
|
+
const olderOpen = currentEngines.has(u.name) ? ` (an older connection from ${u.name} is still open)` : ''
|
|
3966
|
+
theirs = `hivecode ${u.hv}${olderOpen}`
|
|
3772
3967
|
} else if (!human) {
|
|
3773
3968
|
if (!speakForOld) continue
|
|
3774
|
-
|
|
3969
|
+
const olderOpen = currentEngines.has(u.name) ? ` (an older connection from ${u.name} is still open)` : ''
|
|
3970
|
+
theirs = `a hivecode build older than build reporting (before ${VERSION})${olderOpen}`
|
|
3775
3971
|
} else continue
|
|
3776
3972
|
const key = `${u.name}|${u.hv || '-'}`
|
|
3777
3973
|
if (buildWarned.has(key)) continue
|
|
@@ -3993,7 +4189,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3993
4189
|
if (!send.length) return
|
|
3994
4190
|
for (const r of send) queueManifestDelete(r)
|
|
3995
4191
|
flushRoomDoc()
|
|
4192
|
+
const now = Date.now()
|
|
4193
|
+
for (const [k, v] of recentlyLocallyRemovedCase) { if (now - v.at > 60000) recentlyLocallyRemovedCase.delete(k) }
|
|
3996
4194
|
for (const r of send) {
|
|
4195
|
+
recentlyLocallyRemovedCase.set(fold(r), { path: r, at: now })
|
|
3997
4196
|
closeFile(r); known.delete(r); mtimes.delete(r); bases.delete(r); forkBases.delete(r); shadow.delete(r)
|
|
3998
4197
|
if (isCaseInsensitive) {
|
|
3999
4198
|
const cf = fold(r)
|
|
@@ -4244,6 +4443,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4244
4443
|
}
|
|
4245
4444
|
}
|
|
4246
4445
|
}
|
|
4446
|
+
recentlyLocallyRemovedCase.delete(fold(key))
|
|
4247
4447
|
pendingManifestOps.delete(key)
|
|
4248
4448
|
newRemote.delete(key); arrivedNew.delete(key) // deleted while new or queued: nothing to wait for
|
|
4249
4449
|
closeFile(key)
|
|
@@ -4657,6 +4857,41 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4657
4857
|
}
|
|
4658
4858
|
}
|
|
4659
4859
|
|
|
4860
|
+
function stopAll() {
|
|
4861
|
+
try { flushFirstContact() } catch { } // before `stopped`: a join cut short still says what it kept
|
|
4862
|
+
try { settleLocalDeletes() } catch { } // a delete made just before leaving still leaves, judged like any other
|
|
4863
|
+
stopped = true
|
|
4864
|
+
try { flushRoomDoc() } catch { }
|
|
4865
|
+
if (openTimer) { clearImmediate(openTimer); clearTimeout(openTimer); openTimer = null }
|
|
4866
|
+
if (renderBoardTimer) { clearImmediate(renderBoardTimer); renderBoardTimer = null }
|
|
4867
|
+
openQueue.length = 0
|
|
4868
|
+
openQueueSet.clear()
|
|
4869
|
+
if (scanTimer) clearTimeout(scanTimer)
|
|
4870
|
+
if (debounce) { clearTimeout(debounce); debounce = null }
|
|
4871
|
+
for (const tm of collidedTimers.values()) clearTimeout(tm)
|
|
4872
|
+
collidedTimers.clear()
|
|
4873
|
+
pendingFsEvents.clear()
|
|
4874
|
+
pendingFsFullScan = false
|
|
4875
|
+
if (editingClearTimer) clearTimeout(editingClearTimer)
|
|
4876
|
+
try { watcher && watcher.close() } catch { }
|
|
4877
|
+
// The shared socket first: closing it ends every channel at once on the relay, instead of
|
|
4878
|
+
// one CLOSE frame per file down a socket that is about to go anyway.
|
|
4879
|
+
try { fileTransport.stop() } catch { }
|
|
4880
|
+
for (const r of [...fileDocs.keys()]) closeFile(r) // tear down every per-file provider
|
|
4881
|
+
try { provider.awareness.setLocalState(null) } catch { } // announce departure now (don't wait for timeout)
|
|
4882
|
+
try { provider.destroy() } catch { }
|
|
4883
|
+
try { doc.destroy() } catch { }
|
|
4884
|
+
// A room you have left should stop colouring what your agents recall.
|
|
4885
|
+
if (memoryLinked && grmLink) try { grmLink.unlinkRoom(ROOT, log) } catch { }
|
|
4886
|
+
// Last, so a crash anywhere above still frees the folder on the next run via the
|
|
4887
|
+
// stale-PID path rather than leaving it locked.
|
|
4888
|
+
try { releaseLock() } catch { }
|
|
4889
|
+
// A final flush of the ancestor record. The debounced save may still be pending, and
|
|
4890
|
+
// losing it would make the next join see no common ancestor -- the exact condition
|
|
4891
|
+
// that produced the rejoin clobber.
|
|
4892
|
+
try { if (shadowTimer) { clearTimeout(shadowTimer); shadowTimer = null } saveShadow() } catch { }
|
|
4893
|
+
}
|
|
4894
|
+
|
|
4660
4895
|
return {
|
|
4661
4896
|
doc,
|
|
4662
4897
|
provider,
|
|
@@ -5013,7 +5248,19 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
5013
5248
|
const u = s.user
|
|
5014
5249
|
if (!u || !u.name) continue
|
|
5015
5250
|
const prev = seen.get(u.name) || {}
|
|
5016
|
-
|
|
5251
|
+
const hv = (prev.hv === VERSION_LABEL || u.hv === VERSION_LABEL)
|
|
5252
|
+
? VERSION_LABEL
|
|
5253
|
+
: ((prev.listener && !u.listener && u.hv) || (!prev.listener && u.listener && prev.hv) || u.hv || prev.hv)
|
|
5254
|
+
seen.set(u.name, {
|
|
5255
|
+
...prev,
|
|
5256
|
+
...u,
|
|
5257
|
+
hv,
|
|
5258
|
+
editing: u.editing || prev.editing,
|
|
5259
|
+
owner: u.owner || prev.owner,
|
|
5260
|
+
passive: !!(u.passive || prev.passive),
|
|
5261
|
+
listener: !!(u.listener || prev.listener) || undefined,
|
|
5262
|
+
app: u.app || prev.app || undefined
|
|
5263
|
+
})
|
|
5017
5264
|
}
|
|
5018
5265
|
return [...seen.values()]
|
|
5019
5266
|
},
|
|
@@ -5140,40 +5387,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
5140
5387
|
connection: () => ({ connected: !!provider.wsconnected, connecting: !!provider.wsconnecting, ...(folderLost ? { folderLost: folderLost.message } : {}) }),
|
|
5141
5388
|
_openTurns: () => openTurns,
|
|
5142
5389
|
_scanCount: () => scanCount,
|
|
5143
|
-
stop:
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
stopped = true
|
|
5147
|
-
try { flushRoomDoc() } catch { }
|
|
5148
|
-
if (openTimer) { clearImmediate(openTimer); clearTimeout(openTimer); openTimer = null }
|
|
5149
|
-
if (renderBoardTimer) { clearImmediate(renderBoardTimer); renderBoardTimer = null }
|
|
5150
|
-
openQueue.length = 0
|
|
5151
|
-
openQueueSet.clear()
|
|
5152
|
-
if (scanTimer) clearTimeout(scanTimer)
|
|
5153
|
-
if (debounce) { clearTimeout(debounce); debounce = null }
|
|
5154
|
-
for (const tm of collidedTimers.values()) clearTimeout(tm)
|
|
5155
|
-
collidedTimers.clear()
|
|
5156
|
-
pendingFsEvents.clear()
|
|
5157
|
-
pendingFsFullScan = false
|
|
5158
|
-
if (editingClearTimer) clearTimeout(editingClearTimer)
|
|
5159
|
-
try { watcher && watcher.close() } catch { }
|
|
5160
|
-
// The shared socket first: closing it ends every channel at once on the relay, instead of
|
|
5161
|
-
// one CLOSE frame per file down a socket that is about to go anyway.
|
|
5162
|
-
try { fileTransport.stop() } catch { }
|
|
5163
|
-
for (const r of [...fileDocs.keys()]) closeFile(r) // tear down every per-file provider
|
|
5164
|
-
try { provider.awareness.setLocalState(null) } catch { } // announce departure now (don't wait for timeout)
|
|
5165
|
-
try { provider.destroy() } catch { }
|
|
5166
|
-
try { doc.destroy() } catch { }
|
|
5167
|
-
// A room you have left should stop colouring what your agents recall.
|
|
5168
|
-
if (memoryLinked && grmLink) try { grmLink.unlinkRoom(ROOT, log) } catch { }
|
|
5169
|
-
// Last, so a crash anywhere above still frees the folder on the next run via the
|
|
5170
|
-
// stale-PID path rather than leaving it locked.
|
|
5171
|
-
try { releaseLock() } catch { }
|
|
5172
|
-
// A final flush of the ancestor record. The debounced save may still be pending, and
|
|
5173
|
-
// losing it would make the next join see no common ancestor -- the exact condition
|
|
5174
|
-
// that produced the rejoin clobber.
|
|
5175
|
-
try { if (shadowTimer) { clearTimeout(shadowTimer); shadowTimer = null } saveShadow() } catch { }
|
|
5176
|
-
},
|
|
5390
|
+
stop: stopAll,
|
|
5391
|
+
leave, // async: hand pending work over, wait for the relay to accept it, then stop (0.14.3 P0-1)
|
|
5392
|
+
whenSent, // async (file, ms): has the relay accepted this file's local changes yet?
|
|
5177
5393
|
}
|
|
5178
5394
|
}
|
|
5179
5395
|
|