hivecode-mcp 0.14.1 → 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/HIVE_RULES.md +98 -98
- package/LICENSE +21 -21
- package/README.md +421 -421
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -310
- package/docs/GITHUB_WORKFLOW.md +394 -394
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -157
- package/docs/QUICKSTART.md +266 -266
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +474 -474
- package/docs/TROUBLESHOOTING.md +106 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +674 -607
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +3 -1
- package/hive-hook-install.js +179 -179
- package/hive-instructions.js +74 -74
- package/hive-land.js +709 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -591
- package/hive-mcp.js +92 -12
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +354 -351
- package/hive-notify.js +234 -233
- package/hive-obligations.js +247 -247
- package/hive-poll-proto.js +50 -50
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +637 -512
- package/hive-privacy.js +327 -327
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +234 -234
- package/hive-repos.js +430 -430
- package/hive-room-key.js +210 -210
- package/hive-room-mode.js +1761 -1668
- package/hive-room-watcher.js +623 -561
- package/hive-secrets.js +89 -89
- package/hive-session.js +114 -114
- package/hive-setup.js +560 -560
- package/hive-slack-agent.js +408 -408
- package/hive-slack.js +2417 -2367
- package/hive-stdio.js +172 -172
- package/hive-stop-hook.js +293 -293
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -71
- package/hive-transport.js +617 -578
- package/hive-update.js +58 -58
- package/hive-version.js +110 -110
- package/hive-webhook.js +55 -54
- package/icr-merge.js +58 -58
- package/icr.js +856 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +695 -651
- package/lang-json.js +211 -211
- package/lang-python.js +588 -588
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -413
- package/package.json +11 -3
- package/reconcile.cjs +621 -621
- package/slack-app-manifest-agent.json +61 -61
- package/slack-app-manifest-inbound.json +36 -36
- package/slack-app-manifest.json +27 -27
- package/substrate.js +252 -252
- package/sync.js +434 -60
- package/token.js +201 -201
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
|
|
@@ -1069,6 +1096,97 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1069
1096
|
const held = withheld.get(relPath) || (SHARE_SECRETS ? null : newSecrets(content, fe && fe.synced ? fe.text.toString() : '')[0])
|
|
1070
1097
|
return { ok: true, file: relPath, landed, conflicted: hasConflictMarkers(disk), ...(held ? { withheld: describeSecret(held) } : {}) }
|
|
1071
1098
|
}
|
|
1099
|
+
// hive_edit old/new: the agent STATES the lines it edited, so nothing has to guess its base.
|
|
1100
|
+
//
|
|
1101
|
+
// A disk write cannot say which version it was made from, and from the texts alone "B replaced
|
|
1102
|
+
// A's change" and "B wrote from a copy older than A's change" can be identical: four rules that
|
|
1103
|
+
// guessed each deleted work silently (1 Nov 2026), so a plain write still merges against the fork.
|
|
1104
|
+
// Here `old` must be whole lines that appear exactly once in the room's copy. The room's copy is
|
|
1105
|
+
// then the base by construction: B's lines land as written and every other line is untouched. A
|
|
1106
|
+
// stale reader's `old` no longer matches the lines that changed under it, so it is refused, and the
|
|
1107
|
+
// refusal shows the lines as they are now.
|
|
1108
|
+
function editLines(relPath, oldText, newText, intent) {
|
|
1109
|
+
relPath = String(relPath || '').replace(/\\/g, '/')
|
|
1110
|
+
const NOTHING = 'Nothing was written.'
|
|
1111
|
+
if (typeof oldText !== 'string' || typeof newText !== 'string') return { error: `need both old and new. ${NOTHING}` }
|
|
1112
|
+
if (!oldText.trim()) return { error: `old is empty: pass the whole lines you are replacing, exactly as they are now. ${NOTHING}` }
|
|
1113
|
+
if (!relPath || !isSafeRelPath(relPath)) return { error: `unsafe path. ${NOTHING}` }
|
|
1114
|
+
if (folderLost) return { error: folderLost.message }
|
|
1115
|
+
if (SKIP.has(relPath) || isIgnored(relPath)) return { error: `${relPath} is not a syncable file. ${NOTHING}` }
|
|
1116
|
+
if (!canOpen(relPath)) return { error: `${relPath} is out of your scope. ${NOTHING}` }
|
|
1117
|
+
if (!canWrite(relPath)) return { error: `${relPath} is read-only for you. ${NOTHING}` }
|
|
1118
|
+
// The room's copy is only a base while it IS the room's copy: not offline, and not while someone
|
|
1119
|
+
// else holds the file and can change it underneath (antigravity-3, 1 Nov).
|
|
1120
|
+
if (!provider.wsconnected) return { error: `you are not connected to the room, so its copy of ${relPath} cannot be checked. ${NOTHING}` }
|
|
1121
|
+
const holder = coordinator.sense(relPath)
|
|
1122
|
+
if (holder && holder.by && holder.by !== name) return { error: `${holder.by} holds ${relPath}${holder.intent ? ` (${holder.intent})` : ''}, so its lines can change under you. Claim it once they release it. ${NOTHING}` }
|
|
1123
|
+
const fe = fileDocs.get(relPath)
|
|
1124
|
+
if (!fe || !fe.synced) {
|
|
1125
|
+
// In the room but not loaded here (outside the open set, or still arriving): never send the
|
|
1126
|
+
// agent to content mode, which would replace a file it has not read (antigravity-3, 1 Nov).
|
|
1127
|
+
if (inManifest(relPath)) {
|
|
1128
|
+
if (!fe) { try { openFile(relPath) } catch { } }
|
|
1129
|
+
return { loading: true, error: `${relPath} is in the room but not loaded here yet; it is loading now, retry in a few seconds. Do not write it with content: that would replace the room's copy without reading it. ${NOTHING}` }
|
|
1130
|
+
}
|
|
1131
|
+
return { error: `${relPath} is not in the room, so there are no lines to replace. Create it with content instead. ${NOTHING}` }
|
|
1132
|
+
}
|
|
1133
|
+
// The file's own channel, not just the room's: `fe.synced` latches at the first sync, so while
|
|
1134
|
+
// this file's channel is down or catching up its copy can be stale with the room connected, and a
|
|
1135
|
+
// stale match fuses with the newer edit on reconnect (antigravity-3, 1 Nov: "applyTax(subtotal)20").
|
|
1136
|
+
if (!fe.provider || !fe.provider.wsconnected || !fe.provider.synced) return { error: `${relPath}'s own channel is reconnecting, so its current lines cannot be checked; retry in a moment. ${NOTHING}` }
|
|
1137
|
+
// Through the LF view, like every engine read: the room holds LF, and a CRLF disk (a Windows checkout)
|
|
1138
|
+
// compared raw differs on every line, so every old/new edit there was refused (handover-edit H16).
|
|
1139
|
+
let disk = null; try { disk = readDiskText(path.join(ROOT, relPath)) } catch { }
|
|
1140
|
+
const cur = fe.text.toString()
|
|
1141
|
+
// Nothing is sent first: a refused edit sends nothing at all (antigravity-3, 1 Nov). Whatever makes
|
|
1142
|
+
// this disk differ from the room (an unsent save, a key held back, a frozen kept edit, markers) would
|
|
1143
|
+
// be written over by replacing lines in the room's copy, so it is refused.
|
|
1144
|
+
if (disk !== cur) {
|
|
1145
|
+
const why = disk === null ? 'it is missing from your folder'
|
|
1146
|
+
: withheld.has(relPath) ? 'it holds a key that is kept out of the room'
|
|
1147
|
+
: keptUnsent.has(relPath) ? 'it holds an edit that never reached the room, frozen until you save the file again'
|
|
1148
|
+
: hasConflictMarkers(disk) ? 'it carries conflict markers'
|
|
1149
|
+
: 'it has changes the room has not taken yet; if you just saved it, retry in a moment'
|
|
1150
|
+
return { error: `your copy of ${relPath} differs from the room's (${why}). Replacing lines in the room's copy would write over that. ${NOTHING}` }
|
|
1151
|
+
}
|
|
1152
|
+
if (keptUnsent.has(relPath)) keptUnsent.delete(relPath) // identical to the room: nothing left to keep
|
|
1153
|
+
const eol = cur.includes('\r\n') ? '\r\n' : '\n'
|
|
1154
|
+
// One trailing line break is not part of the lines: "b\n" and "b" both mean the line b, so a break
|
|
1155
|
+
// dropped or added on one side can never join two lines or leave a blank one (antigravity-3, 1 Nov).
|
|
1156
|
+
const asLines = (s) => s.replace(/\r?\n$/, '').replace(/\r?\n/g, eol)
|
|
1157
|
+
const o = asLines(oldText), n = asLines(newText)
|
|
1158
|
+
if (!o.trim()) return { error: `old is empty: pass the whole lines you are replacing, exactly as they are now. ${NOTHING}` }
|
|
1159
|
+
const atStart = (i) => i === 0 || cur[i - 1] === '\n'
|
|
1160
|
+
// A match ending in a line break ("b\n" left after stripping "b\n\n") means a blank line follows,
|
|
1161
|
+
// so it may not end at the end of the file; and never between the \r and \n of one line end
|
|
1162
|
+
// (antigravity-3, 1 Nov).
|
|
1163
|
+
const atEnd = (j) => (j === cur.length && !o.endsWith('\n')) || (cur[j] === '\n' && cur[j - 1] !== '\r') || cur.startsWith('\r\n', j)
|
|
1164
|
+
const at = [], whole = []
|
|
1165
|
+
for (let i = cur.indexOf(o); i !== -1; i = cur.indexOf(o, i + 1)) { at.push(i); if (atStart(i) && atEnd(i + o.length)) whole.push(i) }
|
|
1166
|
+
const lineAt = (i) => { const s = cur.lastIndexOf('\n', i - 1) + 1; let e = cur.indexOf('\n', i + o.length); if (e === -1) e = cur.length; return cur.slice(s, e).replace(/\r$/, '') }
|
|
1167
|
+
const last = (() => { try { const rs = fe.ledger ? fe.ledger.toArray() : []; const r = rs[rs.length - 1]; return r && r.name ? ` It was last changed by ${r.name}.` : '' } catch { return '' } })()
|
|
1168
|
+
if (whole.length > 1) return { error: `old appears ${whole.length} times in ${relPath}; add the lines around it so it matches once. ${NOTHING}` }
|
|
1169
|
+
if (!whole.length && at.length) {
|
|
1170
|
+
return { error: `old must be whole lines. In the room's copy it is part of ${at.length === 1 ? 'this line' : 'these lines'}, as ${at.length === 1 ? 'it is' : 'they are'} now:\n${[...new Set(at.map(lineAt))].slice(0, 3).join('\n')}\nIf that is what you edited, pass the whole line.${last} ${NOTHING}` }
|
|
1171
|
+
}
|
|
1172
|
+
if (!whole.length) {
|
|
1173
|
+
// Show the line that looks most like the first line of `old`, so the agent sees what changed.
|
|
1174
|
+
const first = o.split(/\r?\n/).find((l) => l.trim()) || ''
|
|
1175
|
+
const pre = (a, b) => { let k = 0; while (k < a.length && k < b.length && a[k] === b[k]) k++; return k }
|
|
1176
|
+
let best = '', score = 0
|
|
1177
|
+
for (const l of cur.split(/\r?\n/)) { const k = pre(l.trim(), first.trim()); if (k > score) { score = k; best = l } }
|
|
1178
|
+
return { error: `old is not in the room's copy of ${relPath}: it changed since you read it.${last}${score >= 6 ? ` The closest line now is:\n${best}\n` : ' '}Read the file again and pass the lines as they are now. ${NOTHING}` }
|
|
1179
|
+
}
|
|
1180
|
+
if (o === n) return { ok: true, noop: true, file: relPath }
|
|
1181
|
+
let s = whole[0], e = s + o.length
|
|
1182
|
+
// An EMPTY new deletes the lines together with one line break, so no blank line is left behind.
|
|
1183
|
+
// new "\n" is a blank line, not a delete (antigravity-3, 1 Nov).
|
|
1184
|
+
if (!newText) { if (cur.startsWith('\r\n', e)) e += 2; else if (cur[e] === '\n') e += 1; else if (s > 0) s -= (s > 1 && cur[s - 2] === '\r') ? 2 : 1 }
|
|
1185
|
+
const next = cur.slice(0, s) + n + cur.slice(e)
|
|
1186
|
+
// The declared base: this edit was made against the room's copy, by construction.
|
|
1187
|
+
forkBases.set(relPath, cur); shadow.set(relPath, cur)
|
|
1188
|
+
return { ...editFile(relPath, next, intent), via: 'lines' }
|
|
1189
|
+
}
|
|
1072
1190
|
// Detect a live fork and record it. The version set is a CRDT UNION (per-hash keys), so
|
|
1073
1191
|
// this is monotonic + convergent: every peer contributes what it sees and the set grows
|
|
1074
1192
|
// to the full fork on all peers regardless of arrival order. Does NOT touch Y.Text.
|
|
@@ -1242,6 +1360,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1242
1360
|
// "not pulled yet", NOT "new file", so we must not re-seed it from disk (that
|
|
1243
1361
|
// would create a second CRDT history and duplicate/garble content on rejoin).
|
|
1244
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()
|
|
1245
1371
|
|
|
1246
1372
|
// REFUSED FILE-ROOMS. y-websocket retries a failed upgrade forever, with backoff and in
|
|
1247
1373
|
// silence. That is right for a flaky network and badly wrong for a relay that is full: on
|
|
@@ -1344,6 +1470,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1344
1470
|
// LANDED, because an un-awaited open is the same false success one layer down: the caller
|
|
1345
1471
|
// reads the directory, sees nothing, and concludes the file is absent.
|
|
1346
1472
|
function fetchFile(relPath, { timeoutMs = 15000 } = {}) {
|
|
1473
|
+
arrivedNew.delete(relPath) // asked for by name here: in use, so the full grace window applies
|
|
1347
1474
|
const r = String(relPath)
|
|
1348
1475
|
if (!canOpen(r)) return Promise.resolve({ ok: false, reason: `${r} is out of your scope` })
|
|
1349
1476
|
if (collided.has(r)) {
|
|
@@ -1400,6 +1527,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1400
1527
|
function openFile(relPath) {
|
|
1401
1528
|
let e = fileDocs.get(relPath)
|
|
1402
1529
|
if (e) return e
|
|
1530
|
+
if (newRemote.delete(relPath)) arrivedNew.add(relPath)
|
|
1403
1531
|
const fdoc = new Y.Doc()
|
|
1404
1532
|
const fprovider = fileTransport.provide(fileRoom(room, relPath), fdoc)
|
|
1405
1533
|
const text = fdoc.getText('content')
|
|
@@ -1431,8 +1559,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1431
1559
|
// each peer's DISK — never into the shared text — so conflict markers can't fuse.
|
|
1432
1560
|
const conflict = fdoc.getMap('conflict')
|
|
1433
1561
|
const undo = new Y.UndoManager(text, { captureTimeout: 350 }) // default tracks local-origin txns
|
|
1434
|
-
|
|
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 }
|
|
1435
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) })
|
|
1436
1571
|
// From the mtime scan() already took, not two more syscalls per open: a 1,000-file join opens
|
|
1437
1572
|
// 1,000 files, and fileCreationTs() still falls back to a stat for any file this missed.
|
|
1438
1573
|
if (!createdTimes.has(relPath) && mtimes.has(relPath)) createdTimes.set(relPath, mtimes.get(relPath))
|
|
@@ -1454,6 +1589,18 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1454
1589
|
reconcile(relPath, 'remote')
|
|
1455
1590
|
detectFork(relPath)
|
|
1456
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
|
|
1594
|
+
// A new file that has just landed on disk frees its place for the next one (a burst of new
|
|
1595
|
+
// files moves at sync speed, not at the 30 s grace), and lets waiting new files in.
|
|
1596
|
+
if (arrivedNew.has(relPath)) {
|
|
1597
|
+
const t = setTimeout(() => {
|
|
1598
|
+
if (stopped) return
|
|
1599
|
+
if (fileDocs.size > maxOpen()) evictIfOverCap(null)
|
|
1600
|
+
if (openQueue.length && !openTimer) openTimer = setImmediate(drainOpenQueue)
|
|
1601
|
+
}, 50)
|
|
1602
|
+
if (t.unref) t.unref()
|
|
1603
|
+
}
|
|
1457
1604
|
}
|
|
1458
1605
|
})
|
|
1459
1606
|
// A refused upgrade lands here. The provider will keep retrying by itself, which is the
|
|
@@ -1478,8 +1625,91 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1478
1625
|
// its bound — so this is where the least-recently-used ones are let go.
|
|
1479
1626
|
touched.set(relPath, Date.now())
|
|
1480
1627
|
evictIfOverCap(relPath)
|
|
1628
|
+
scheduleEvictRecheck()
|
|
1481
1629
|
return e
|
|
1482
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
|
+
}
|
|
1483
1713
|
function closeFile(relPath) {
|
|
1484
1714
|
const e = fileDocs.get(relPath)
|
|
1485
1715
|
if (!e) return
|
|
@@ -1529,6 +1759,41 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1529
1759
|
const touched = new Map() // relPath -> last use (ms)
|
|
1530
1760
|
const touchFile = (relPath) => { if (fileDocs.has(relPath)) touched.set(relPath, Date.now()) }
|
|
1531
1761
|
let evicted = 0
|
|
1762
|
+
// NEW FILES GET IN EVEN AT THE BOUND (Claude-cloud, 8 Nov 2026). A file a teammate creates AFTER
|
|
1763
|
+
// this client joined, with no copy on this disk, used to be skipped outright once the working set
|
|
1764
|
+
// was full: the manifest observer queued it, queueOpenFile returned at the bound, and nothing ever
|
|
1765
|
+
// came back for it -- measured: never arrived in 180 s, mux or per-file, in any room fuller than
|
|
1766
|
+
// HIVE_MAX_OPEN_FILES (and a 640-file room is fuller than the per-file 128). Such a key now opens
|
|
1767
|
+
// anyway, and evictIfOverCap makes room by letting go of the least recently used. Bounded: at
|
|
1768
|
+
// most NEW_HEADROOM over the bound at once, so a burst of hundreds of new files cannot open
|
|
1769
|
+
// hundreds of sockets; the rest WAIT in the queue (never dropped) until the re-check frees room.
|
|
1770
|
+
// Files that were in the room at join keep the old trade ("the rest sync when touched").
|
|
1771
|
+
const newRemote = new Set() // keys added to the room after join, not on this disk, not yet opened
|
|
1772
|
+
// ...and the ones that were opened that way and have not been used here since. Once such a file is
|
|
1773
|
+
// ON DISK it may be let go without waiting out the grace window: it arrived, which is all it was
|
|
1774
|
+
// opened for. LRU order still decides, so older idle files go first.
|
|
1775
|
+
const arrivedNew = new Set()
|
|
1776
|
+
const NEW_HEADROOM = 32
|
|
1777
|
+
let evictRecheck = null
|
|
1778
|
+
// evictIfOverCap only runs when a file opens, and a file inside its grace window cannot be let
|
|
1779
|
+
// go -- so a set pushed over the bound by new files would stay over it. Look again once the
|
|
1780
|
+
// grace has passed, evict down to the bound, and let any waiting new files in.
|
|
1781
|
+
function scheduleEvictRecheck() {
|
|
1782
|
+
if (evictRecheck || stopped) return
|
|
1783
|
+
if (fileDocs.size <= maxOpen() && !newRemote.size) return
|
|
1784
|
+
// While new files are landing or waiting, look again every second: a landed new file is
|
|
1785
|
+
// evictable as soon as it is on disk, and that must not wait out a grace window that could be
|
|
1786
|
+
// minutes long. Otherwise (only grace-protected files over the bound) look once the grace ends.
|
|
1787
|
+
const ms = (newRemote.size || arrivedNew.size) ? 1000 : EVICT_GRACE_MS + 1000
|
|
1788
|
+
evictRecheck = setTimeout(() => {
|
|
1789
|
+
evictRecheck = null
|
|
1790
|
+
if (stopped) return
|
|
1791
|
+
evictIfOverCap(null)
|
|
1792
|
+
if (openQueue.length && !openTimer) openTimer = setImmediate(drainOpenQueue)
|
|
1793
|
+
scheduleEvictRecheck()
|
|
1794
|
+
}, ms)
|
|
1795
|
+
if (evictRecheck.unref) evictRecheck.unref()
|
|
1796
|
+
}
|
|
1532
1797
|
function evictIfOverCap(keep) {
|
|
1533
1798
|
if (fileDocs.size <= maxOpen()) return
|
|
1534
1799
|
const now = Date.now()
|
|
@@ -1536,18 +1801,20 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1536
1801
|
const cand = []
|
|
1537
1802
|
for (const [r, e] of fileDocs) {
|
|
1538
1803
|
if (r === keep || !e.synced) continue
|
|
1804
|
+
if (!isSentNow(r)) continue // closing it would drop an edit the relay has not accepted yet
|
|
1539
1805
|
if (conflicted.has(r)) continue
|
|
1540
1806
|
try { if (e.conflict && e.conflict.size) continue } catch { }
|
|
1541
1807
|
if (isClaimed(r)) continue
|
|
1542
1808
|
const t = touched.get(r) || 0
|
|
1543
|
-
|
|
1809
|
+
const landedNew = arrivedNew.has(r) && fs.existsSync(path.join(ROOT, r))
|
|
1810
|
+
if (now - t < EVICT_GRACE_MS && !landedNew) continue
|
|
1544
1811
|
cand.push([r, t])
|
|
1545
1812
|
}
|
|
1546
1813
|
cand.sort((a, b) => a[1] - b[1]) // least recently used first
|
|
1547
1814
|
let over = fileDocs.size - maxOpen()
|
|
1548
1815
|
for (const [r] of cand) {
|
|
1549
1816
|
if (over-- <= 0) break
|
|
1550
|
-
closeFile(r); touched.delete(r); evicted++
|
|
1817
|
+
closeFile(r); touched.delete(r); arrivedNew.delete(r); evicted++
|
|
1551
1818
|
}
|
|
1552
1819
|
}
|
|
1553
1820
|
|
|
@@ -1565,6 +1832,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1565
1832
|
const cand = []
|
|
1566
1833
|
for (const [r, e] of fileDocs) {
|
|
1567
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
|
|
1568
1836
|
try { if (e.conflict && e.conflict.size) continue } catch { }
|
|
1569
1837
|
cand.push([r, touched.get(r) || 0])
|
|
1570
1838
|
}
|
|
@@ -1572,7 +1840,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1572
1840
|
let over = fileDocs.size - cap
|
|
1573
1841
|
for (const [r] of cand) {
|
|
1574
1842
|
if (over-- <= 0) break
|
|
1575
|
-
closeFile(r); touched.delete(r); evicted++
|
|
1843
|
+
closeFile(r); touched.delete(r); arrivedNew.delete(r); evicted++
|
|
1576
1844
|
}
|
|
1577
1845
|
}
|
|
1578
1846
|
|
|
@@ -1737,6 +2005,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1737
2005
|
// merge. Keyed by room — the same folder rejoined against a different room must not
|
|
1738
2006
|
// inherit an ancestor from unrelated history.
|
|
1739
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()
|
|
1740
2011
|
const bases = new Map() // last synced content per file (advances on every reconcile)
|
|
1741
2012
|
const forkBases = new Map() // FORK POINT: what THIS author last saw/authored per file.
|
|
1742
2013
|
// forkBases advances only on local authorship / first adoption — NOT when a
|
|
@@ -1936,6 +2207,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1936
2207
|
const fold = (r) => isCaseInsensitive ? r.toLowerCase() : r
|
|
1937
2208
|
|
|
1938
2209
|
const canonicalCaseKeys = new Map() // fold(r) -> canonical relPath
|
|
2210
|
+
const recentlyLocallyRemovedCase = new Map() // fold(r) -> { path: r, at: Date.now() }
|
|
1939
2211
|
const collided = new Map() // collided relPath -> { with: canonical relPath }
|
|
1940
2212
|
const collidedWarned = new Set() // relPath announced to room
|
|
1941
2213
|
const collidedTimers = new Map() // relPath -> Timeout (debounced room announcement)
|
|
@@ -2263,6 +2535,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2263
2535
|
}
|
|
2264
2536
|
if (!fe.synced && origin !== 'local') return
|
|
2265
2537
|
touchFile(relPath)
|
|
2538
|
+
if (origin !== 'remote') arrivedNew.delete(relPath) // a local change: an ordinary file now (see reconcile)
|
|
2266
2539
|
|
|
2267
2540
|
if (origin === 'local') {
|
|
2268
2541
|
if (!canWrite(relPath)) {
|
|
@@ -2414,8 +2687,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2414
2687
|
// (duplicated/garbled content on rejoin). So: open if needed, then defer until
|
|
2415
2688
|
// the 'sync' handler re-runs us. Once synced, "" genuinely means empty.
|
|
2416
2689
|
fe = fileDocs.get(relPath)
|
|
2417
|
-
|
|
2418
|
-
|
|
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 }
|
|
2419
2694
|
// A KEPT UNSENT EDIT IS FROZEN until its author saves the file again. Merging it against the
|
|
2420
2695
|
// room in either direction would decide something nobody decided: the room's next change would
|
|
2421
2696
|
// be written over it, and an unchanged save merged against itself pulled the room's old copy over
|
|
@@ -2439,6 +2714,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2439
2714
|
else if (withheld.has(relPath)) clearWithheld(relPath)
|
|
2440
2715
|
}
|
|
2441
2716
|
touchFile(relPath) // reconciling a file IS using it — keep it in the working set
|
|
2717
|
+
// ...but only a LOCAL change makes a just-arrived new file an ordinary one with the full grace.
|
|
2718
|
+
// A remote reconcile is the arrival itself, and must not take away its early eviction.
|
|
2719
|
+
if (origin !== 'remote') arrivedNew.delete(relPath)
|
|
2442
2720
|
// SOURCE CAPTURE (fork gate): before ANYTHING in this pass can merge over or render into
|
|
2443
2721
|
// this file, record any authored edit sitting on disk. The edit enters the provenance
|
|
2444
2722
|
// DAG first; whatever happens to the disk afterwards can no longer lose it.
|
|
@@ -2488,6 +2766,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2488
2766
|
// clean again -> a resolution landed elsewhere; adopt it (order-independent w/ observer).
|
|
2489
2767
|
if (disk !== null && hasConflictMarkers(disk) && !hasConflictMarkers(fe.text.toString())) { adoptResolved(relPath); return }
|
|
2490
2768
|
}
|
|
2769
|
+
dropStackedAwaits(relPath, fe, disk) // before anything reads the room's copy (0.14.3 P0-5-3)
|
|
2491
2770
|
const docText = fe.text.toString()
|
|
2492
2771
|
// An empty shared doc is authoritative ONLY if we have history for this path.
|
|
2493
2772
|
// A file's NAME lives in the parent doc and its CONTENT in the file's own doc, on two
|
|
@@ -2531,6 +2810,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2531
2810
|
}
|
|
2532
2811
|
if (disk === docText) {
|
|
2533
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
|
+
}
|
|
2534
2821
|
// CREATING a file is editing it. This branch is where a locally-authored NEW file
|
|
2535
2822
|
// lands: scan() finds it, openFile() syncs the content up, and by the time reconcile
|
|
2536
2823
|
// runs disk and doc already agree -- so it returns below, before the markEditing() at
|
|
@@ -2662,7 +2949,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2662
2949
|
return
|
|
2663
2950
|
}
|
|
2664
2951
|
}
|
|
2665
|
-
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) }
|
|
2666
2953
|
if (r0.text !== disk) writeToDisk(relPath, r0.text)
|
|
2667
2954
|
if (r0.conflict) conflicted.add(relPath)
|
|
2668
2955
|
bases.set(relPath, r0.text); forkBases.set(relPath, r0.text); shadow.set(relPath, r0.text)
|
|
@@ -2675,6 +2962,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2675
2962
|
// local copy may be newer than the room's (edited while disconnected, or the room
|
|
2676
2963
|
// came back stale). This is the data-loss case that was seen live.
|
|
2677
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)
|
|
2678
2985
|
// ...unless the room never got it. Capturing a local edit moves the ancestor to the edited
|
|
2679
2986
|
// bytes BEFORE the relay accepts them. A write held behind someone else's claim
|
|
2680
2987
|
// (HIVE_ENFORCE_CLAIMS=queue) is discarded when its writer disconnects, so on rejoin
|
|
@@ -2711,7 +3018,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2711
3018
|
// the local side first so the pre-merge bytes are recoverable regardless of outcome.
|
|
2712
3019
|
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy before rejoin merge' })
|
|
2713
3020
|
const rj = icrMerge3(anc, disk, docText, relPath)
|
|
2714
|
-
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) }
|
|
2715
3022
|
if (rj.text !== disk) writeToDisk(relPath, rj.text)
|
|
2716
3023
|
if (rj.conflict) conflicted.add(relPath)
|
|
2717
3024
|
bases.set(relPath, rj.text); forkBases.set(relPath, rj.text); shadow.set(relPath, rj.text)
|
|
@@ -2720,7 +3027,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2720
3027
|
say(unsent
|
|
2721
3028
|
? `⚠ ${relPath}: ${name}'s last edit never reached the room — it was held while someone else had the file, and ${name}'s connection dropped. ` +
|
|
2722
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.`
|
|
2723
|
-
:
|
|
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.`)
|
|
2724
3033
|
} catch { }
|
|
2725
3034
|
return
|
|
2726
3035
|
}
|
|
@@ -2747,7 +3056,22 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2747
3056
|
// Refuse adoption: keep local disk untouched, keep room doc untouched, snapshot local, and announce loudly.
|
|
2748
3057
|
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: `local copy preserved at join (${st.state}: room copy refused)` })
|
|
2749
3058
|
bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
2750
|
-
|
|
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`)
|
|
2751
3075
|
noteFirstContact('kept', relPath)
|
|
2752
3076
|
return
|
|
2753
3077
|
}
|
|
@@ -2838,7 +3162,13 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2838
3162
|
res = { text: disk, conflict: false } // author built on the latest or cleanly rewrote — trust it
|
|
2839
3163
|
} else {
|
|
2840
3164
|
res = icrMerge3(fork, disk, docText, relPath)
|
|
2841
|
-
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
|
+
}
|
|
2842
3172
|
// OPEN (1 Nov 2026): a clean handover onto the same lines still shows markers. Four rounds of
|
|
2843
3173
|
// rules that decided it from the texts (a claim-time fork, who-holds-it, line counts, shared
|
|
2844
3174
|
// tokens) each silently deleted someone's work in a case antigravity-3 then built: from the
|
|
@@ -2878,7 +3208,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2878
3208
|
icrWarned.set(relPath, why)
|
|
2879
3209
|
log(`[${name}] !! REFUSED a fused merge of ${relPath}: ${why}`)
|
|
2880
3210
|
try {
|
|
2881
|
-
say(`[!] @all ${relPath}: a merge was REFUSED because it fused rather than merged — ${why}. Nothing was written: the room's copy and ${name}'s local copy are both intact and unchanged. Someone must reconcile them deliberately
|
|
3211
|
+
say(`[!] @all ${relPath}: a merge was REFUSED because it fused rather than merged — ${why}. Nothing was written: the room's copy and ${name}'s local copy are both intact and unchanged. Someone must reconcile them deliberately: a plain file write, or hive_edit with content, merges against the other version again.`)
|
|
2882
3212
|
} catch { /* the log still carries it */ }
|
|
2883
3213
|
}
|
|
2884
3214
|
return
|
|
@@ -2911,7 +3241,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2911
3241
|
// Gate ON: the author's INTENT was already captured at the source (captureLocal, top
|
|
2912
3242
|
// of this pass) — here we only advance the authority head to the landed content.
|
|
2913
3243
|
// Gate OFF (default): the proven landed-transition stamp, unchanged.
|
|
2914
|
-
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) } }
|
|
2915
3245
|
else if (res.text !== docText) stampProvenance(relPath, docText, res.text)
|
|
2916
3246
|
}
|
|
2917
3247
|
if (res.text !== disk) writeToDisk(relPath, res.text)
|
|
@@ -2924,7 +3254,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2924
3254
|
if (hasMarkers && !conflicted.has(relPath)) {
|
|
2925
3255
|
conflicted.add(relPath)
|
|
2926
3256
|
log(`[${name}] ⚠ merge conflict in ${relPath} — kept BOTH versions with <<<<<<< markers`)
|
|
2927
|
-
try { say(`⚠ MERGE CONFLICT in ${relPath} — it has <<<<<<< markers. Whoever owns it: resolve before continuing.`) } catch { }
|
|
3257
|
+
try { say(`⚠ MERGE CONFLICT in ${relPath} — it has <<<<<<< markers. Whoever owns it: resolve before continuing, by saving the file without them or by passing the marked block as old to hive_edit old/new.`) } catch { }
|
|
2928
3258
|
} else if (!hasMarkers && conflicted.has(relPath)) {
|
|
2929
3259
|
conflicted.delete(relPath)
|
|
2930
3260
|
try { say(`✓ conflict in ${relPath} resolved.`) } catch { }
|
|
@@ -3614,20 +3944,30 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3614
3944
|
function checkBuilds() {
|
|
3615
3945
|
// An old engine cannot speak for itself, so SOMEBODY current must -- but only one: the reporting
|
|
3616
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.
|
|
3617
3948
|
const reporters = [name]
|
|
3618
|
-
|
|
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
|
+
}
|
|
3619
3956
|
const speakForOld = reporters.sort()[0] === name
|
|
3957
|
+
|
|
3620
3958
|
for (const s of provider.awareness.getStates().values()) {
|
|
3621
3959
|
const u = s && s.user
|
|
3622
|
-
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
|
|
3623
3961
|
const human = String(u.kind || '').toLowerCase() === 'human'
|
|
3624
3962
|
let theirs = ''
|
|
3625
3963
|
if (u.hv) {
|
|
3626
3964
|
if (u.hv === VERSION_LABEL || name > u.name) continue
|
|
3627
|
-
|
|
3965
|
+
const olderOpen = currentEngines.has(u.name) ? ` (an older connection from ${u.name} is still open)` : ''
|
|
3966
|
+
theirs = `hivecode ${u.hv}${olderOpen}`
|
|
3628
3967
|
} else if (!human) {
|
|
3629
3968
|
if (!speakForOld) continue
|
|
3630
|
-
|
|
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}`
|
|
3631
3971
|
} else continue
|
|
3632
3972
|
const key = `${u.name}|${u.hv || '-'}`
|
|
3633
3973
|
if (buildWarned.has(key)) continue
|
|
@@ -3849,7 +4189,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3849
4189
|
if (!send.length) return
|
|
3850
4190
|
for (const r of send) queueManifestDelete(r)
|
|
3851
4191
|
flushRoomDoc()
|
|
4192
|
+
const now = Date.now()
|
|
4193
|
+
for (const [k, v] of recentlyLocallyRemovedCase) { if (now - v.at > 60000) recentlyLocallyRemovedCase.delete(k) }
|
|
3852
4194
|
for (const r of send) {
|
|
4195
|
+
recentlyLocallyRemovedCase.set(fold(r), { path: r, at: now })
|
|
3853
4196
|
closeFile(r); known.delete(r); mtimes.delete(r); bases.delete(r); forkBases.delete(r); shadow.delete(r)
|
|
3854
4197
|
if (isCaseInsensitive) {
|
|
3855
4198
|
const cf = fold(r)
|
|
@@ -4027,26 +4370,33 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4027
4370
|
const key = openQueue.shift()
|
|
4028
4371
|
openQueueSet.delete(key)
|
|
4029
4372
|
if (fileDocs.size >= maxOpen()) {
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4373
|
+
// At the bound a key from the join sweep waits for a touch, as before -- but a NEW file is
|
|
4374
|
+
// never dropped: it opens within the headroom, or goes back to the head of the queue for
|
|
4375
|
+
// the re-check (scheduleEvictRecheck) to let in once room is made.
|
|
4376
|
+
if (!newRemote.has(key)) continue
|
|
4377
|
+
if (fileDocs.size >= maxOpen() + NEW_HEADROOM) {
|
|
4378
|
+
openQueue.unshift(key); openQueueSet.add(key)
|
|
4379
|
+
scheduleEvictRecheck()
|
|
4380
|
+
break
|
|
4381
|
+
}
|
|
4033
4382
|
}
|
|
4034
4383
|
// STILL WANTED? A key can wait here while a peer deletes the file. Its document still exists on
|
|
4035
4384
|
// the relay, so opening it would bring the deleted file back to this disk and publish it again
|
|
4036
4385
|
// for everyone. Wanted = still in the room, or on this disk (a new local file not yet published).
|
|
4037
4386
|
const wanted = inManifest(key) || fs.existsSync(path.join(ROOT, key))
|
|
4387
|
+
if (!wanted) newRemote.delete(key)
|
|
4038
4388
|
if (wanted && canOpen(key) && !SKIP.has(key) && !isIgnored(key) && !fileDocs.has(key)) {
|
|
4039
4389
|
openFile(key)
|
|
4040
4390
|
openedCount++
|
|
4041
4391
|
}
|
|
4042
4392
|
}
|
|
4043
|
-
if (openQueue.length && !stopped && fileDocs.size < maxOpen()) {
|
|
4393
|
+
if (openQueue.length && !stopped && (fileDocs.size < maxOpen() || (newRemote.size && fileDocs.size < maxOpen() + NEW_HEADROOM))) {
|
|
4044
4394
|
openTimer = setImmediate(drainOpenQueue)
|
|
4045
4395
|
}
|
|
4046
4396
|
}
|
|
4047
4397
|
|
|
4048
4398
|
function queueOpenFile(key) {
|
|
4049
|
-
if (fileDocs.has(key) || fileDocs.size >= maxOpen() || openQueueSet.has(key)) return
|
|
4399
|
+
if (fileDocs.has(key) || (fileDocs.size >= maxOpen() && !newRemote.has(key)) || openQueueSet.has(key)) return
|
|
4050
4400
|
openQueueSet.add(key)
|
|
4051
4401
|
if (fs.existsSync(path.join(ROOT, key))) openQueue.unshift(key)
|
|
4052
4402
|
else openQueue.push(key)
|
|
@@ -4093,7 +4443,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4093
4443
|
}
|
|
4094
4444
|
}
|
|
4095
4445
|
}
|
|
4446
|
+
recentlyLocallyRemovedCase.delete(fold(key))
|
|
4096
4447
|
pendingManifestOps.delete(key)
|
|
4448
|
+
newRemote.delete(key); arrivedNew.delete(key) // deleted while new or queued: nothing to wait for
|
|
4097
4449
|
closeFile(key)
|
|
4098
4450
|
{ const qi = openQueue.indexOf(key); if (qi >= 0) { openQueue.splice(qi, 1); openQueueSet.delete(key) } } // deleted while queued
|
|
4099
4451
|
if (!stamp) stamp = deletedStamp() // one folder per batch, so a bulk delete comes back together
|
|
@@ -4109,7 +4461,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4109
4461
|
for (const [key, change] of others) {
|
|
4110
4462
|
if (canOpen(key) && !SKIP.has(key) && !isIgnored(key)) {
|
|
4111
4463
|
checkCaseCollision(key)
|
|
4112
|
-
|
|
4464
|
+
// NEW = created after this client joined (roomAtJoin is the room at the first sync) and not
|
|
4465
|
+
// on this disk. The join's own manifest arrives before roomAtJoin exists, so it is never new.
|
|
4466
|
+
const isNew = !!roomAtJoin && change.action === 'add' && !roomAtJoin.has(key) && !fs.existsSync(path.join(ROOT, key))
|
|
4467
|
+
if (isNew) newRemote.add(key)
|
|
4468
|
+
if (ev.changes.keys.size <= 2 && (fileDocs.size < maxOpen() || (isNew && fileDocs.size < maxOpen() + NEW_HEADROOM))) {
|
|
4113
4469
|
openFile(key)
|
|
4114
4470
|
} else {
|
|
4115
4471
|
queueOpenFile(key)
|
|
@@ -4501,6 +4857,41 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4501
4857
|
}
|
|
4502
4858
|
}
|
|
4503
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
|
+
|
|
4504
4895
|
return {
|
|
4505
4896
|
doc,
|
|
4506
4897
|
provider,
|
|
@@ -4821,6 +5212,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4821
5212
|
forkInfo, // the active fork on a file as a resolvable object (base + versions + signed intents), or null
|
|
4822
5213
|
resolveFork, // propose the reconciled file; ICR-validated (parse + dangling) before it lands, refused otherwise
|
|
4823
5214
|
edit: editFile, // first-class authored write: intent + transition stamped race-free, then normal reconcile
|
|
5215
|
+
editLines, // hive_edit old/new: whole lines found once in the room's copy, which is then the base
|
|
4824
5216
|
// --- non-writer review & approval ---
|
|
4825
5217
|
review,
|
|
4826
5218
|
reviews: getReviews,
|
|
@@ -4856,7 +5248,19 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4856
5248
|
const u = s.user
|
|
4857
5249
|
if (!u || !u.name) continue
|
|
4858
5250
|
const prev = seen.get(u.name) || {}
|
|
4859
|
-
|
|
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
|
+
})
|
|
4860
5264
|
}
|
|
4861
5265
|
return [...seen.values()]
|
|
4862
5266
|
},
|
|
@@ -4952,6 +5356,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4952
5356
|
// how this room actually works — both are things an operator can only act on if
|
|
4953
5357
|
// they can see them.
|
|
4954
5358
|
openCap: maxOpen(),
|
|
5359
|
+
newWaiting: newRemote.size,
|
|
4955
5360
|
// 'mux' = every file on this client's one socket; 'socket' = a socket per file.
|
|
4956
5361
|
transport: fileTransport.mode,
|
|
4957
5362
|
evicted,
|
|
@@ -4982,40 +5387,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4982
5387
|
connection: () => ({ connected: !!provider.wsconnected, connecting: !!provider.wsconnecting, ...(folderLost ? { folderLost: folderLost.message } : {}) }),
|
|
4983
5388
|
_openTurns: () => openTurns,
|
|
4984
5389
|
_scanCount: () => scanCount,
|
|
4985
|
-
stop:
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
stopped = true
|
|
4989
|
-
try { flushRoomDoc() } catch { }
|
|
4990
|
-
if (openTimer) { clearImmediate(openTimer); clearTimeout(openTimer); openTimer = null }
|
|
4991
|
-
if (renderBoardTimer) { clearImmediate(renderBoardTimer); renderBoardTimer = null }
|
|
4992
|
-
openQueue.length = 0
|
|
4993
|
-
openQueueSet.clear()
|
|
4994
|
-
if (scanTimer) clearTimeout(scanTimer)
|
|
4995
|
-
if (debounce) { clearTimeout(debounce); debounce = null }
|
|
4996
|
-
for (const tm of collidedTimers.values()) clearTimeout(tm)
|
|
4997
|
-
collidedTimers.clear()
|
|
4998
|
-
pendingFsEvents.clear()
|
|
4999
|
-
pendingFsFullScan = false
|
|
5000
|
-
if (editingClearTimer) clearTimeout(editingClearTimer)
|
|
5001
|
-
try { watcher && watcher.close() } catch { }
|
|
5002
|
-
// The shared socket first: closing it ends every channel at once on the relay, instead of
|
|
5003
|
-
// one CLOSE frame per file down a socket that is about to go anyway.
|
|
5004
|
-
try { fileTransport.stop() } catch { }
|
|
5005
|
-
for (const r of [...fileDocs.keys()]) closeFile(r) // tear down every per-file provider
|
|
5006
|
-
try { provider.awareness.setLocalState(null) } catch { } // announce departure now (don't wait for timeout)
|
|
5007
|
-
try { provider.destroy() } catch { }
|
|
5008
|
-
try { doc.destroy() } catch { }
|
|
5009
|
-
// A room you have left should stop colouring what your agents recall.
|
|
5010
|
-
if (memoryLinked && grmLink) try { grmLink.unlinkRoom(ROOT, log) } catch { }
|
|
5011
|
-
// Last, so a crash anywhere above still frees the folder on the next run via the
|
|
5012
|
-
// stale-PID path rather than leaving it locked.
|
|
5013
|
-
try { releaseLock() } catch { }
|
|
5014
|
-
// A final flush of the ancestor record. The debounced save may still be pending, and
|
|
5015
|
-
// losing it would make the next join see no common ancestor -- the exact condition
|
|
5016
|
-
// that produced the rejoin clobber.
|
|
5017
|
-
try { if (shadowTimer) { clearTimeout(shadowTimer); shadowTimer = null } saveShadow() } catch { }
|
|
5018
|
-
},
|
|
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?
|
|
5019
5393
|
}
|
|
5020
5394
|
}
|
|
5021
5395
|
|