hivecode-mcp 0.11.6 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HIVE_RULES.md +127 -127
- package/README.md +119 -69
- package/core.js +14 -146
- package/docs/MCP.md +19 -11
- package/docs/SELF_HOSTING.md +256 -0
- package/hive-activity.js +310 -0
- package/hive-mcp.js +447 -76
- package/hive-mux-proto.js +58 -0
- package/hive-mux.js +350 -0
- package/hive-pr-staleness.js +189 -0
- package/hive-privacy.js +288 -0
- package/hive-relay-health.js +225 -0
- package/hive-setup.js +445 -0
- package/hive-stdio.js +168 -0
- package/hive-update.js +58 -0
- package/hive-version.js +59 -0
- package/lang-js.js +11 -2
- package/limits.js +49 -1
- package/package.json +29 -7
- package/reconcile.cjs +477 -0
- package/sync.js +613 -43
package/sync.js
CHANGED
|
@@ -20,6 +20,7 @@ import os from 'os'
|
|
|
20
20
|
import path from 'path'
|
|
21
21
|
import * as Y from 'yjs'
|
|
22
22
|
import { WebsocketProvider } from 'y-websocket'
|
|
23
|
+
import { createFileTransport } from './hive-mux.js' // ONE SOCKET PER PARTICIPANT: every file doc on one physical socket when the relay offers it
|
|
23
24
|
import { WebSocket } from 'ws'
|
|
24
25
|
import ignore from 'ignore'
|
|
25
26
|
import { applyDiff, summarizeChange, changedRange, hasConflictMarkers, merge3 } from './core.js'
|
|
@@ -36,6 +37,13 @@ import { genIdentity, authorChange, verifyReceipt, contentHash } from './substra
|
|
|
36
37
|
// whole file is the most common thing an agent does, so this fired constantly.
|
|
37
38
|
import grmLink from './grm-link.cjs' // hands this room's history to GRM, so the ROOM has a memory
|
|
38
39
|
import { structuralMerge, supports, languageFor } from './icr.js' // validates a judged fork resolution before it may land (parse + dangling refs)
|
|
40
|
+
import { accountFor, sameText, DESCENDS, IDENTICAL, DIVERGED, UNPROVEN } from './hive-pr-staleness.js' // room-versus-branch provenance staleness checks (HIV-P9)
|
|
41
|
+
// HIV-P8. The map key MUST be the string 'obligations' — hive-mcp.js:656 reads that exact key, and
|
|
42
|
+
// a debt written anywhere else would be invisible to hive_owed: a warning nobody can see, which is
|
|
43
|
+
// the defect this ticket is about, one level down.
|
|
44
|
+
import * as RH from './hive-relay-health.js' // HIV-P13; the ONLY producer of the not-syncing diagnosis
|
|
45
|
+
import { checkRelay, currentRelayFor } from './hive-privacy.js' // HIV-P20; which relay is this, and did anybody choose it
|
|
46
|
+
import * as OB from './hive-obligations.js'
|
|
39
47
|
|
|
40
48
|
// Wire-format/protocol version. Bump when a change makes clients incompatible
|
|
41
49
|
// (e.g. the v2 per-file-subdoc model). Broadcast via awareness so peers can warn
|
|
@@ -159,7 +167,7 @@ asked to collaborate here, follow this.
|
|
|
159
167
|
## Join the room
|
|
160
168
|
1. If you do NOT have a \`hive_join\` tool and you can run terminal commands, register
|
|
161
169
|
the Hivecode MCP server, then reload so the tools load:
|
|
162
|
-
claude mcp add hivecode -- npx -y hivecode-mcp
|
|
170
|
+
claude mcp add hivecode -- npx -y hivecode-mcp@latest
|
|
163
171
|
2. Call: hive_join({ dir: ".", name: "<your name>", owner: "<the human you work for>" })
|
|
164
172
|
The room, relay, and token are already saved in .hive.json in this folder, so you
|
|
165
173
|
do NOT need a link — hive_join reads them automatically.
|
|
@@ -405,7 +413,7 @@ export function acquireFolderLock(root, room, log = console.log) {
|
|
|
405
413
|
return release
|
|
406
414
|
}
|
|
407
415
|
|
|
408
|
-
export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir = '.', name = 'anon', kind = 'human', owner = '', token = '', license = '', log = console.log, syncFiles = true }) {
|
|
416
|
+
export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir = '.', name = 'anon', kind = 'human', owner = '', token = '', license = '', log = console.log, syncFiles = true, passive = false }) {
|
|
409
417
|
// BEFORE ANY DISK OR SOCKET WORK. A name outside the mention grammar joins successfully and is
|
|
410
418
|
// then unreachable: its mail, its obligations and its wake-on-mention are all silently dead,
|
|
411
419
|
// and the only symptom is other people being told "@X is NOT in the room" about someone who is
|
|
@@ -420,6 +428,32 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
420
428
|
e.suggested = nameCheck.suggested
|
|
421
429
|
throw e
|
|
422
430
|
}
|
|
431
|
+
// THE SAME PRIVACY GATE hive-mcp.js applies at join, repeated here because this is the
|
|
432
|
+
// real choke point. Every client in the product -- the MCP server, hive-agent.js, the
|
|
433
|
+
// CLIs, every demo script -- reaches the network through this one function, and a guard
|
|
434
|
+
// that lives only in the MCP layer protects only agents that went through the MCP layer.
|
|
435
|
+
// An operator who sets HIVE_RELAY_PIN is entitled to expect it means "this machine",
|
|
436
|
+
// not "this machine, except for the seven scripts that call startSync directly".
|
|
437
|
+
//
|
|
438
|
+
// It throws for the reason the name check above throws: startSync has no failure channel,
|
|
439
|
+
// and a client that half-connects to a relay nobody authorised is the failure this exists
|
|
440
|
+
// to prevent. Refusing here means no socket was opened and no file was published.
|
|
441
|
+
// A retired public relay is followed to its replacement here too, because every node client
|
|
442
|
+
// (hive-agent.js, hive-say.js, the demos) reaches the network through this function and not
|
|
443
|
+
// through hive-mcp.js's resolveRoom. The privacy check below then judges the relay actually
|
|
444
|
+
// dialled, not the one that was asked for.
|
|
445
|
+
const moved = currentRelayFor(relay)
|
|
446
|
+
if (moved.migratedFrom) {
|
|
447
|
+
relay = moved.relay
|
|
448
|
+
try { log(`relay ${moved.migratedFrom} has been retired; using ${relay}`) } catch { /* log is optional */ }
|
|
449
|
+
}
|
|
450
|
+
const priv = checkRelay({ relay, env: process.env, dir: path.resolve(dir), fs })
|
|
451
|
+
if (!priv.ok) {
|
|
452
|
+
const e = new Error(`${priv.headline}\n\n${priv.detail}`)
|
|
453
|
+
e.code = 'EHIVERELAYPIN'
|
|
454
|
+
e.qcode = priv.code
|
|
455
|
+
throw e
|
|
456
|
+
}
|
|
423
457
|
const ROOT = path.resolve(dir)
|
|
424
458
|
fs.mkdirSync(ROOT, { recursive: true })
|
|
425
459
|
// FIRST, before any socket or file work: if another client already holds this folder,
|
|
@@ -942,9 +976,19 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
942
976
|
if (lic) wsParams.license = lic
|
|
943
977
|
if (name) wsParams.name = String(name).slice(0, 48)
|
|
944
978
|
if (kind) wsParams.kind = kind
|
|
979
|
+
if (passive) wsParams.passive = 'true'
|
|
945
980
|
const wsOpts = { WebSocketPolyfill: WebSocket, disableBc: true, params: Object.keys(wsParams).length ? wsParams : undefined }
|
|
981
|
+
// ONE SOCKET PER PARTICIPANT. When the relay offers it (it says so in /health), file docs stop
|
|
982
|
+
// costing a socket each: they ride one physical socket as channels, and the relay admits each
|
|
983
|
+
// one through the same gates a per-file socket passes. Against a relay that does not offer it,
|
|
984
|
+
// or with HIVE_MUX=0, every file gets its own WebsocketProvider exactly as before. Decided once,
|
|
985
|
+
// before the first file opens, so a session never straddles the two transports.
|
|
986
|
+
// onFallback: the relay stopped offering it mid-session, so trim to the per-file bound before
|
|
987
|
+
// each remaining file gets a socket of its own (trimForSockets, below).
|
|
988
|
+
const fileTransport = createFileTransport({ relay, room, wsOpts, log, onFallback: () => trimForSockets() })
|
|
989
|
+
let stopped = false
|
|
946
990
|
const provider = new WebsocketProvider(relay, room, doc, wsOpts)
|
|
947
|
-
provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, v: PROTOCOL_VERSION }) // identity is implicit in the client
|
|
991
|
+
provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, v: PROTOCOL_VERSION, passive: !!passive }) // identity is implicit in the client
|
|
948
992
|
|
|
949
993
|
// ---- relay reachability, said out loud exactly once -------------------------------
|
|
950
994
|
// y-websocket retries forever and silently. That is the right RECOVERY behaviour and
|
|
@@ -985,7 +1029,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
985
1029
|
`counts CONNECTIONS, not people: one agent holding several files holds several. If you run the relay, ` +
|
|
986
1030
|
`raise it with HIVE_SEATS; otherwise ask whoever does, or contact ${SUPPORT}.`
|
|
987
1031
|
if (code === 503) return `the relay is not serving this connection. Either the whole relay is at capacity ` +
|
|
988
|
-
`(Q430) or it could not check your licence and is failing closed rather than guessing. It may also be ` +
|
|
1032
|
+
`(Q430 sockets, Q438 open files, or Q439 files held by one participant) or it could not check your licence and is failing closed rather than guessing. It may also be ` +
|
|
989
1033
|
`restarting -- if this lasts, contact ${SUPPORT}.`
|
|
990
1034
|
if (code) return `the relay answered ${code} instead of accepting the connection. Contact ${SUPPORT}.`
|
|
991
1035
|
return `the relay at ${relay} cannot be reached. It may be down, asleep, or blocked by a network. ` +
|
|
@@ -1053,12 +1097,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1053
1097
|
// usually a whole-relay problem rather than a per-file one, so one message naming the
|
|
1054
1098
|
// count is the useful shape -- one per file would be 800 lines of chat.
|
|
1055
1099
|
const refusedFiles = new Map() // relPath -> consecutive connection errors, cleared on sync
|
|
1100
|
+
const refusedCodes = new Map() // relPath -> the HTTP status the relay refused with, when it gave one
|
|
1101
|
+
const refusedReasons = new Map() // relPath -> the relay's own words (a mux refusal carries its Q-code line)
|
|
1056
1102
|
let refusalSaidAt = 0
|
|
1057
1103
|
let refusalTimer = null
|
|
1104
|
+
|
|
1058
1105
|
function noteRefusals() {
|
|
1059
1106
|
if (refusalTimer) return
|
|
1060
1107
|
// Debounced: a full relay refuses every file at once, and we want the total, not the first.
|
|
1061
|
-
refusalTimer = setTimeout(() => {
|
|
1108
|
+
refusalTimer = setTimeout(async () => {
|
|
1062
1109
|
refusalTimer = null
|
|
1063
1110
|
const stuck = [...refusedFiles.keys()].filter((r) => { const e = fileDocs.get(r); return e && !e.synced })
|
|
1064
1111
|
// One transient error on one file is a blip, not an outage. Two or more files stuck, or
|
|
@@ -1068,22 +1115,129 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1068
1115
|
const now = Date.now()
|
|
1069
1116
|
if (now - refusalSaidAt < 300000) return // at most once every 5 minutes
|
|
1070
1117
|
refusalSaidAt = now
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1118
|
+
// HIV-P13. THIS MESSAGE USED TO NAME A CAUSE IT HAD NOT MEASURED.
|
|
1119
|
+
//
|
|
1120
|
+
// It said "the usual cause is the relay being full (Q430)" on the strength of having
|
|
1121
|
+
// observed only that N file docs have synced=false. Measured 21 Sep 2026, at the moment a
|
|
1122
|
+
// third agent printed it for 49 files: the relay reported 56 connections of 512, and
|
|
1123
|
+
// limits.js keys its per-room count on the FILE-room, so any one file held three
|
|
1124
|
+
// connections against a cap of 64. Q430 needed 512 and had 56; Q431 needed 64 and had 3.
|
|
1125
|
+
// Neither refusal could have fired. The real event was the relay dropping its room state
|
|
1126
|
+
// -- /health went rooms 44 -> 0 -> 29 -> 44 over a few minutes -- and every client
|
|
1127
|
+
// reconnecting one socket per file.
|
|
1128
|
+
//
|
|
1129
|
+
// "The relay is full" and "the relay just dropped everyone and we are all reconnecting"
|
|
1130
|
+
// produce an identical symptom and need OPPOSITE responses: a config change, or thirty
|
|
1131
|
+
// seconds of patience. Telling an operator to raise a limit that is 89% free is worse
|
|
1132
|
+
// than saying nothing, because they will do it, see no change, and conclude the product
|
|
1133
|
+
// is broken -- correctly, on the evidence they were given.
|
|
1134
|
+
//
|
|
1135
|
+
// So the client asks the relay instead of guessing, and reports what came back. It also
|
|
1136
|
+
// stops printing the literal string "<relay-http>", which it did for three weeks while
|
|
1137
|
+
// holding the relay URL in scope.
|
|
1138
|
+
// EVIDENCE BEFORE INFERENCE. If the relay actually refused us it said so with a status,
|
|
1139
|
+
// and explain() maps those to the right cause and the right knob -- Q431 to HIVE_SEATS,
|
|
1140
|
+
// Q430 to capacity, 401/403 to the token, 402 to the plan. That is a fact from the relay.
|
|
1141
|
+
// Only when nothing refused us -- the sockets simply never came up, which is what a
|
|
1142
|
+
// dropped-and-reconnecting relay looks like -- is there anything left to infer, and then
|
|
1143
|
+
// we go and ASK rather than assert.
|
|
1144
|
+
const coded = stuck.map((r) => refusedCodes.get(r)).filter(Boolean)
|
|
1145
|
+
const code = coded.length ? coded.sort((a, b) => coded.filter((c) => c === b).length - coded.filter((c) => c === a).length)[0] : null
|
|
1146
|
+
const msg = code
|
|
1147
|
+
? `${stuck.length} file(s) are not synced and will READ AS EMPTY until this clears. ` +
|
|
1148
|
+
`The relay refused ${coded.length} of them with ${code}` +
|
|
1149
|
+
// The relay's own reason outranks any mapping of the bare number: one code covers
|
|
1150
|
+
// several ceilings, and only the relay knows which one it hit.
|
|
1151
|
+
(() => { const why = stuck.map((r) => refusedCodes.get(r) === code && refusedReasons.get(r)).find(Boolean); return why ? ` ("${String(why).slice(0, 160)}")` : '' })() +
|
|
1152
|
+
`: ${explain(code)} ` +
|
|
1153
|
+
`Do not trust this folder's contents meanwhile.`
|
|
1154
|
+
: (await RH.explainNotSyncing(relay, {
|
|
1155
|
+
stuck: stuck.length, what: 'files',
|
|
1156
|
+
trailer: `Nothing refused these connections with a status code, so the cause above is ` +
|
|
1157
|
+
`inferred from the relay's own counters rather than reported by it. ` +
|
|
1158
|
+
`Do not trust this folder's contents meanwhile.`,
|
|
1159
|
+
})).text
|
|
1077
1160
|
log(`[${name}] !! ${msg}`)
|
|
1078
1161
|
try { say(`[!] @all ${msg}`) } catch { /* chat may itself be unreachable — the log still carries it */ }
|
|
1079
1162
|
}, 4000)
|
|
1080
1163
|
if (refusalTimer.unref) refusalTimer.unref()
|
|
1081
1164
|
}
|
|
1165
|
+
// HIV-P18. THE DOCUMENTED REMEDY DID NOT EXIST.
|
|
1166
|
+
//
|
|
1167
|
+
// The README told every user: "A file outside the working set is not on your disk yet. It
|
|
1168
|
+
// exists in the room. Reading or claiming the path fetches it." Measured 21 Sep 2026 against
|
|
1169
|
+
// a client holding 8 of a room's 41 files, on a path that WAS in the manifest:
|
|
1170
|
+
//
|
|
1171
|
+
// whyNotShared(mod024.js) -> "" (in scope; edits would reach the room)
|
|
1172
|
+
// claim(mod024.js) -> true ("it's yours")
|
|
1173
|
+
// after claim + 9s -> on disk = false
|
|
1174
|
+
// after release + 9s -> on disk = false
|
|
1175
|
+
// provenanceOf -> 0 receipts, headOf -> null (doc never opened)
|
|
1176
|
+
// public fetch/read/open -> undefined
|
|
1177
|
+
//
|
|
1178
|
+
// Nothing brought the bytes down. The cause is one line in reconcile: `if (disk === null)
|
|
1179
|
+
// return` before openFile, plus a warm-up that only opens files already on local disk. A
|
|
1180
|
+
// manifest entry with no local copy was never opened, so it could never arrive.
|
|
1181
|
+
//
|
|
1182
|
+
// The only thing that DID work was writing to the path -- which is precisely the blind
|
|
1183
|
+
// recreate HIV-P14b guards. So the sole recovery route was the one dangerous action, reached
|
|
1184
|
+
// by an agent that had been told "it's yours" and then saw an empty directory. That is
|
|
1185
|
+
// HIV-P3's class arriving inbound: a claim granted over bytes that cannot move.
|
|
1186
|
+
//
|
|
1187
|
+
// This makes the sentence true. `fetch` opens the doc and RESOLVES WHEN THE CONTENT HAS
|
|
1188
|
+
// LANDED, because an un-awaited open is the same false success one layer down: the caller
|
|
1189
|
+
// reads the directory, sees nothing, and concludes the file is absent.
|
|
1190
|
+
function fetchFile(relPath, { timeoutMs = 15000 } = {}) {
|
|
1191
|
+
const r = String(relPath)
|
|
1192
|
+
if (!canOpen(r)) return Promise.resolve({ ok: false, reason: `${r} is out of your scope` })
|
|
1193
|
+
const full = path.join(ROOT, r)
|
|
1194
|
+
// Already here: report it, and say so rather than implying we went and got it.
|
|
1195
|
+
if (fs.existsSync(full)) return Promise.resolve({ ok: true, alreadyLocal: true, file: r })
|
|
1196
|
+
// Not in the room either. This is the case @Hermes hit, and it is NOT the working-set
|
|
1197
|
+
// case: nothing will ever arrive, so saying "fetching" would be a lie. The two look
|
|
1198
|
+
// identical from an empty directory and have opposite remedies, so they get different
|
|
1199
|
+
// answers here.
|
|
1200
|
+
if (!manifest.has(r)) {
|
|
1201
|
+
return Promise.resolve({
|
|
1202
|
+
ok: false, notInRoom: true, file: r,
|
|
1203
|
+
reason: `${r} is not in this room at all — no client has ever published it, so nothing ` +
|
|
1204
|
+
`can fetch it. This is not a working-set miss. Check the room's .gitignore allow-list ` +
|
|
1205
|
+
`covers the path, then have whoever holds the file write it into their synced folder.`,
|
|
1206
|
+
})
|
|
1207
|
+
}
|
|
1208
|
+
const e = openFile(r)
|
|
1209
|
+
if (!e) return Promise.resolve({ ok: false, reason: `could not open ${r}` })
|
|
1210
|
+
return new Promise((resolve) => {
|
|
1211
|
+
let done = false
|
|
1212
|
+
const finish = (res) => { if (done) return; done = true; clearInterval(iv); clearTimeout(to); resolve(res) }
|
|
1213
|
+
const check = () => {
|
|
1214
|
+
if (!e.synced) return
|
|
1215
|
+
// reconcile() runs on the provider's own sync event and writes to disk. Wait for the
|
|
1216
|
+
// BYTES, not for the flag -- the flag says the socket settled, which is not the claim
|
|
1217
|
+
// being made to the caller.
|
|
1218
|
+
if (fs.existsSync(full)) return finish({ ok: true, file: r, bytes: fs.statSync(full).size })
|
|
1219
|
+
// Synced and still nothing: the room holds this path with empty content. Real, and a
|
|
1220
|
+
// different answer from "not fetched yet".
|
|
1221
|
+
if (e.text.toString() === '') return finish({ ok: true, file: r, empty: true, bytes: 0 })
|
|
1222
|
+
}
|
|
1223
|
+
const iv = setInterval(check, 250)
|
|
1224
|
+
if (iv.unref) iv.unref()
|
|
1225
|
+
const to = setTimeout(() => finish({
|
|
1226
|
+
ok: false, file: r, timedOut: true,
|
|
1227
|
+
reason: `${r} is in the room but did not arrive within ${timeoutMs}ms. The file's own ` +
|
|
1228
|
+
`socket may be refused — hive_status reports filesHeld of roomFiles, and a refused ` +
|
|
1229
|
+
`file doc is indistinguishable from an empty file.`,
|
|
1230
|
+
}), timeoutMs)
|
|
1231
|
+
if (to.unref) to.unref()
|
|
1232
|
+
check()
|
|
1233
|
+
})
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1082
1236
|
function openFile(relPath) {
|
|
1083
1237
|
let e = fileDocs.get(relPath)
|
|
1084
1238
|
if (e) return e
|
|
1085
1239
|
const fdoc = new Y.Doc()
|
|
1086
|
-
const fprovider =
|
|
1240
|
+
const fprovider = fileTransport.provide(fileRoom(room, relPath), fdoc)
|
|
1087
1241
|
const text = fdoc.getText('content')
|
|
1088
1242
|
// `snap` (in the FILE's own doc, so it's scope-protected with the file) holds
|
|
1089
1243
|
// restore points: id -> { id, file, by, at, ts, content, churn, kind, label }.
|
|
@@ -1125,9 +1279,19 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1125
1279
|
// A refused upgrade lands here. The provider will keep retrying by itself, which is the
|
|
1126
1280
|
// right behaviour and exactly why nothing surfaces it — so count it and let noteRefusals
|
|
1127
1281
|
// decide whether this is a blip or a relay that will not take us.
|
|
1128
|
-
fprovider.on('connection-error', () => {
|
|
1282
|
+
fprovider.on('connection-error', (ev) => {
|
|
1129
1283
|
if (e.synced) return // already have the content; a later blip is the provider's business
|
|
1130
1284
|
refusedFiles.set(relPath, (refusedFiles.get(relPath) || 0) + 1)
|
|
1285
|
+
// HIV-P13. THE STATUS CODE WAS THROWN AWAY HERE, and it is the only authoritative evidence
|
|
1286
|
+
// in the whole path. joinRoom in hive-mcp.js already captured it from this same event; this
|
|
1287
|
+
// handler took no argument at all, so noteRefusals had nothing but "N files are quiet" and
|
|
1288
|
+
// had to INFER a cause -- which is how it came to announce Q430 on 21 Sep while the relay
|
|
1289
|
+
// sat at 56 of 512. explain() above maps these codes correctly and has done since 5 Sep.
|
|
1290
|
+
// A mux refusal carries the code; a per-file socket's ErrorEvent carries it only in its
|
|
1291
|
+
// message, which is why this read nothing at all on the socket path.
|
|
1292
|
+
const code = RH.statusFromError(ev)
|
|
1293
|
+
if (code) refusedCodes.set(relPath, code)
|
|
1294
|
+
if (ev && typeof ev.reason === 'string' && ev.reason) refusedReasons.set(relPath, ev.reason)
|
|
1131
1295
|
noteRefusals()
|
|
1132
1296
|
})
|
|
1133
1297
|
// Newly opened counts as used, and opening is the moment the working set can go over
|
|
@@ -1171,7 +1335,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1171
1335
|
// - a file in an unresolved conflict (its fork record is live and local-only)
|
|
1172
1336
|
// - a file anyone currently holds a claim on (they are mid-edit)
|
|
1173
1337
|
// - anything used within the grace window
|
|
1174
|
-
|
|
1338
|
+
//
|
|
1339
|
+
// THE BOUND DEPENDS ON THE TRANSPORT. With a socket per file it is what keeps one client from
|
|
1340
|
+
// filling the relay, and the rule above applies. In one-socket mode a file costs a channel, not
|
|
1341
|
+
// a socket, so the bound only caps memory -- set high enough that an ordinary repo is held
|
|
1342
|
+
// whole, which is what ends "this folder is incomplete on purpose". HIVE_MAX_OPEN_FILES wins
|
|
1343
|
+
// either way. A function, not a constant: the transport is known only once /health answers.
|
|
1344
|
+
const MAX_OPEN_SET = Number(process.env.HIVE_MAX_OPEN_FILES) || 0
|
|
1345
|
+
const maxOpen = () => Math.max(8, MAX_OPEN_SET || (fileTransport.mode === 'mux' ? 4096 : 128))
|
|
1175
1346
|
// Configurable because the grace window is what makes eviction untestable otherwise: in a
|
|
1176
1347
|
// short run every file was touched seconds ago, so nothing is ever eligible.
|
|
1177
1348
|
const EVICT_GRACE_MS = Number(process.env.HIVE_EVICT_GRACE_MS) || 30000
|
|
@@ -1179,7 +1350,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1179
1350
|
const touchFile = (relPath) => { if (fileDocs.has(relPath)) touched.set(relPath, Date.now()) }
|
|
1180
1351
|
let evicted = 0
|
|
1181
1352
|
function evictIfOverCap(keep) {
|
|
1182
|
-
if (fileDocs.size <=
|
|
1353
|
+
if (fileDocs.size <= maxOpen()) return
|
|
1183
1354
|
const now = Date.now()
|
|
1184
1355
|
const isClaimed = (r) => { try { return claims.has(r) } catch { return false } }
|
|
1185
1356
|
const cand = []
|
|
@@ -1193,7 +1364,32 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1193
1364
|
cand.push([r, t])
|
|
1194
1365
|
}
|
|
1195
1366
|
cand.sort((a, b) => a[1] - b[1]) // least recently used first
|
|
1196
|
-
let over = fileDocs.size -
|
|
1367
|
+
let over = fileDocs.size - maxOpen()
|
|
1368
|
+
for (const [r] of cand) {
|
|
1369
|
+
if (over-- <= 0) break
|
|
1370
|
+
closeFile(r); touched.delete(r); evicted++
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// THE FALLBACK TRIM. The relay stopped offering one-socket mode mid-session, and every file still
|
|
1375
|
+
// open is about to cost a socket of its own. Keep the most recently used up to the per-file bound
|
|
1376
|
+
// and close the rest -- UNSYNCED ones too, unlike evictIfOverCap: on this path "unsynced" means
|
|
1377
|
+
// "was waiting on the socket that just went away", and its bytes are on disk or still in the
|
|
1378
|
+
// room, so closing it costs latency, not work. Conflicted and claimed files are kept whatever
|
|
1379
|
+
// the count, because closing those could. Without this, a joiner mid-way through fetching 900
|
|
1380
|
+
// files would open 900 sockets at once -- the storm the per-file bound exists to prevent.
|
|
1381
|
+
function trimForSockets() {
|
|
1382
|
+
const cap = maxOpen()
|
|
1383
|
+
if (fileDocs.size <= cap) return
|
|
1384
|
+
const isClaimed = (r) => { try { return claims.has(r) } catch { return false } }
|
|
1385
|
+
const cand = []
|
|
1386
|
+
for (const [r, e] of fileDocs) {
|
|
1387
|
+
if (conflicted.has(r) || isClaimed(r)) continue
|
|
1388
|
+
try { if (e.conflict && e.conflict.size) continue } catch { }
|
|
1389
|
+
cand.push([r, touched.get(r) || 0])
|
|
1390
|
+
}
|
|
1391
|
+
cand.sort((a, b) => a[1] - b[1]) // least recently used first
|
|
1392
|
+
let over = fileDocs.size - cap
|
|
1197
1393
|
for (const [r] of cand) {
|
|
1198
1394
|
if (over-- <= 0) break
|
|
1199
1395
|
closeFile(r); touched.delete(r); evicted++
|
|
@@ -1237,6 +1433,46 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1237
1433
|
// almost every legitimate claim in exactly the rooms that need this guard most, and it would
|
|
1238
1434
|
// be switched off within a day. So we only judge things we have positive reason to believe
|
|
1239
1435
|
// are FILES: known to the room, present on disk, or carrying a directory separator.
|
|
1436
|
+
// HIV-P3, 20 Sep 2026. THE GENESIS CASE: the five tests above are all false for a root-level
|
|
1437
|
+
// file that DOES NOT EXIST YET, which is the most common reason anyone claims at all -- you
|
|
1438
|
+
// claim `config.env` precisely because you are about to create it. Found by DeepMind-Agent.
|
|
1439
|
+
// So HIV-27's guard had a hole in exactly the case it was written for: claim succeeds, write
|
|
1440
|
+
// succeeds, the local gate passes because in YOUR folder the file is real, and the bytes never
|
|
1441
|
+
// leave. Born invisible, every signal green.
|
|
1442
|
+
//
|
|
1443
|
+
// THE ONLY QUESTION IS: is this bare string a FILE-TO-BE or a LOGICAL LABEL? Nothing on disk
|
|
1444
|
+
// and nothing in the room can answer it, so this is a guess, and the direction of the guess is
|
|
1445
|
+
// what matters. Guessing "file" too eagerly refuses legitimate region claims -- and under an
|
|
1446
|
+
// allow-list .gitignore that means refusing ALMOST ALL of them, which gets the guard switched
|
|
1447
|
+
// off within a day and costs more than the hole. So every rule here must be one a LABEL would
|
|
1448
|
+
// essentially never satisfy.
|
|
1449
|
+
//
|
|
1450
|
+
// THE DIGIT RULE IS THE LOAD-BEARING ONE and it is not obvious. A trailing dot-segment
|
|
1451
|
+
// containing a LETTER is an extension: .js .env .secret. One that is ALL DIGITS is a version
|
|
1452
|
+
// number: v1.2, release-0.11.6, HIV-P3.5. Without that distinction every ticket id and release
|
|
1453
|
+
// label in this room's own vocabulary gets refused as a file.
|
|
1454
|
+
//
|
|
1455
|
+
// WHAT THIS DOES NOT CLOSE, stated rather than discovered later. Extensionless root files that
|
|
1456
|
+
// are neither conventional nor `*file` shaped -- a bare `config`, `staging`, `hosts` -- are
|
|
1457
|
+
// still read as labels and still go invisible. The hole narrows; it does not close. It cannot
|
|
1458
|
+
// close here, because at claim time the string is all we have. The rest belongs at WRITE time,
|
|
1459
|
+
// where a path that appears on disk inside the room folder is a fact and needs no heuristic.
|
|
1460
|
+
// That is HIV-P8, and it is filed rather than quietly assumed.
|
|
1461
|
+
const EXTENSIONLESS_ROOT_FILES = new Set([
|
|
1462
|
+
'LICENSE', 'LICENCE', 'README', 'CHANGELOG', 'CHANGES', 'NOTICE', 'AUTHORS', 'CONTRIBUTORS',
|
|
1463
|
+
'CODEOWNERS', 'COPYING', 'INSTALL', 'CONTRIBUTING', 'VERSION', 'MAINTAINERS', 'TODO',
|
|
1464
|
+
])
|
|
1465
|
+
const looksLikeFilename = (p) => {
|
|
1466
|
+
if (/\s/.test(p)) return false // "the parser rewrite", "P3 lane"
|
|
1467
|
+
if (/\.[^.]*[A-Za-z][^.]*$/.test(p)) return true // .js .env .secret -- but NOT .2 .5 .6
|
|
1468
|
+
if (EXTENSIONLESS_ROOT_FILES.has(p.toUpperCase())) return true
|
|
1469
|
+
// Makefile, Dockerfile, Rakefile, Gemfile, Procfile, Vagrantfile, Justfile, Caddyfile.
|
|
1470
|
+
// Capital-initial and case-SENSITIVE on purpose: lowercase "profile" and "logfile" are
|
|
1471
|
+
// perfectly ordinary region names and must stay claimable.
|
|
1472
|
+
if (/^[A-Z][A-Za-z]+file$/.test(p)) return true
|
|
1473
|
+
return false
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1240
1476
|
const whyNotShared = (relPath) => {
|
|
1241
1477
|
const p = String(relPath || '')
|
|
1242
1478
|
if (!p) return ''
|
|
@@ -1245,6 +1481,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1245
1481
|
// SKIP counts as positive evidence: those names ARE files by definition, and a control
|
|
1246
1482
|
// file sits in the room root with no separator in its name, so the heuristics below miss it.
|
|
1247
1483
|
const isFile = manifest.has(p) || fileDocs.has(p) || SKIP.has(p) || onDisk || p.includes('/')
|
|
1484
|
+
|| looksLikeFilename(p) // HIV-P3: the genesis case -- see above
|
|
1248
1485
|
if (!isFile) return '' // a label, not a path -- not ours to judge
|
|
1249
1486
|
if (!isSafeRelPath(p)) return `"${p}" is not a safe relative path, so it can never be synced`
|
|
1250
1487
|
if (SKIP.has(p)) return `${p} is a Hivecode control file, not shared source`
|
|
@@ -1260,6 +1497,12 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1260
1497
|
// files" (adopt the room's copy) and "this file was created during the session"
|
|
1261
1498
|
// (a creation race -- merge, never adopt). Populated once, on the first scan.
|
|
1262
1499
|
const preexisting = new Set()
|
|
1500
|
+
// HIV-P14b. Manifest keys as of our first sync: what the ROOM held before we arrived.
|
|
1501
|
+
// `preexisting` records our DISK, which structurally cannot distinguish "nobody had this file"
|
|
1502
|
+
// from "the room had it all along and we never fetched it". null until the first sync, and null
|
|
1503
|
+
// must never be read as "the room was empty" -- that would classify every joiner's first
|
|
1504
|
+
// creation as a race, which is the bug this exists to fix.
|
|
1505
|
+
let roomAtJoin = null
|
|
1263
1506
|
let firstScanDone = false
|
|
1264
1507
|
const shadow = new Map() // relPath -> exact bytes THIS ENGINE last wrote/captured (source-level edit capture)
|
|
1265
1508
|
// THE SAME THING, FROM THE PREVIOUS SESSION, and the reason it exists:
|
|
@@ -1283,15 +1526,28 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1283
1526
|
// work (it would look like a deliberate deletion against an up-to-date base).
|
|
1284
1527
|
|
|
1285
1528
|
const rel = (full) => path.relative(ROOT, full).split(path.sep).join('/')
|
|
1286
|
-
|
|
1529
|
+
// HIV-P8: `skipped` collects the files this walk DECLINES to sync, and that second list is the
|
|
1530
|
+
// whole ticket. Everything in this engine is derived from "the files we sync", so every natural
|
|
1531
|
+
// place to notice a born-invisible file is definitionally blind to it: walk() dropped ignored
|
|
1532
|
+
// paths, so scan() never saw them, so reconcile() was never called for them, so the guard at the
|
|
1533
|
+
// top of reconcile() was unreachable for exactly the class of file it names. Hooking there would
|
|
1534
|
+
// have passed a unit test that called reconcile() directly and done nothing in a real session.
|
|
1535
|
+
//
|
|
1536
|
+
// DIRECTORY PRUNING IS UNCHANGED, deliberately. Descending into ignored directories to find
|
|
1537
|
+
// ignored files would walk vendor and build trees on every tick. Measured on a real checkout with
|
|
1538
|
+
// pruning kept: 7934 synced, 256 ignored files visible, 280 directories descended, 191ms for the
|
|
1539
|
+
// whole traversal — and that traversal already happens. We are keeping an answer isIgnored()
|
|
1540
|
+
// already computed instead of discarding it. An ignored file inside an ignored directory stays
|
|
1541
|
+
// unseen, which is the correct trade at that price.
|
|
1542
|
+
function walk(d, acc = [], skipped = []) {
|
|
1287
1543
|
let entries = []
|
|
1288
1544
|
try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { return acc }
|
|
1289
1545
|
for (const e of entries) {
|
|
1290
1546
|
if (IGNORE.has(e.name)) continue
|
|
1291
1547
|
const full = path.join(d, e.name)
|
|
1292
1548
|
const r = rel(full)
|
|
1293
|
-
if (e.isDirectory()) { if (!isIgnored(r + '/')) walk(full, acc) }
|
|
1294
|
-
else if (e.isFile()) { if (
|
|
1549
|
+
if (e.isDirectory()) { if (!isIgnored(r + '/')) walk(full, acc, skipped) } // prune ignored dirs
|
|
1550
|
+
else if (e.isFile()) { if (isIgnored(r)) skipped.push(full); else acc.push(full) }
|
|
1295
1551
|
}
|
|
1296
1552
|
return acc
|
|
1297
1553
|
}
|
|
@@ -1441,10 +1697,19 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1441
1697
|
// A genuine emptying of a file we HAVE synced still has an ancestor, so it falls through
|
|
1442
1698
|
// to the merge below and still lands.
|
|
1443
1699
|
const noHistory = !bases.has(relPath) && !priorShadow.has(relPath)
|
|
1700
|
+
// HIV-P14b tracing, env-gated. Which branch a blind recreate actually takes was guessed
|
|
1701
|
+
// wrong twice from reading the code; this answers it from the running product.
|
|
1702
|
+
if (process.env.HIVE_P14_DEBUG && new RegExp(process.env.HIVE_P14_DEBUG).test(relPath)) {
|
|
1703
|
+
log(`[P14] ${relPath} origin=${origin} synced=${fe.synced} docLen=${docText.length} ` +
|
|
1704
|
+
`diskLen=${disk === null ? 'null' : disk.length} hasBase=${bases.has(relPath)} ` +
|
|
1705
|
+
`priorShadow=${priorShadow.has(relPath)} inManifest=${manifest.has(relPath)} ` +
|
|
1706
|
+
`preexisting=${preexisting.has(relPath)}`)
|
|
1707
|
+
}
|
|
1444
1708
|
if (docText === '' && (!manifest.has(relPath) || (disk && noHistory))) {
|
|
1445
1709
|
// the relay has no usable copy of this path -> publish ours (a genuinely new file,
|
|
1446
1710
|
// or one whose registration outlived the join that promised it)
|
|
1447
1711
|
if (disk === null) return
|
|
1712
|
+
if (process.env.HIVE_P14_DEBUG) log(`[P14] BRANCH=genesis-publish ${relPath}`)
|
|
1448
1713
|
fe.doc.transact(() => applyDiff(fe.text, disk))
|
|
1449
1714
|
stampProvenance(relPath, '', disk) // genesis: sign the file's first content (base = empty)
|
|
1450
1715
|
manifest.set(relPath, 1)
|
|
@@ -1492,6 +1757,72 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1492
1757
|
// disjoint additions union (the normal case -- each author added their own
|
|
1493
1758
|
// block), and a real overlap raises conflict markers for a human to settle
|
|
1494
1759
|
// instead of the engine quietly picking a winner on arrival order.
|
|
1760
|
+
// HIV-P14b, OPEN AND DELIBERATELY NOT IMPLEMENTED HERE YET.
|
|
1761
|
+
//
|
|
1762
|
+
// There is a third case this dichotomy misses: the path appeared on OUR disk after we
|
|
1763
|
+
// started AND the room already had it before we joined. That is not a race -- the agent
|
|
1764
|
+
// could not see the file (bounded working set, nothing touched it), believed it did not
|
|
1765
|
+
// exist, and wrote one. Measured 21 Sep 2026: agent B recreated mod039.js and agent A's
|
|
1766
|
+
// copy ended up with two const declarations of the same name.
|
|
1767
|
+
//
|
|
1768
|
+
// `preexisting` records our DISK, so it structurally cannot tell those apart; the manifest
|
|
1769
|
+
// as of first sync can. A guard on that was written and REMOVED before shipping, because
|
|
1770
|
+
// the probe showed it never firing -- most likely the manifest is not yet populated at the
|
|
1771
|
+
// instant the base provider reports sync, so the snapshot came back empty. A refusal path
|
|
1772
|
+
// that silently never runs is worse than none: it reads as protection in review and does
|
|
1773
|
+
// nothing in production, which is the defect class this repo keeps hitting.
|
|
1774
|
+
//
|
|
1775
|
+
// What ships instead is the reporting half, which is verified: status() now exposes
|
|
1776
|
+
// filesHeld/roomFiles/filesMissing instead of a full house, and the join announces the gap
|
|
1777
|
+
// with the count and the remedy. An agent told "33 files exist here that you do not have,
|
|
1778
|
+
// reading the path fetches it" will not recreate them. That removes the cause; the guard
|
|
1779
|
+
// is the belt for when it happens anyway, and it lands when it is proven to fire.
|
|
1780
|
+
if (process.env.HIVE_P14_DEBUG) log(`[P14] BRANCH=no-base-block ${relPath} preexisting=${preexisting.has(relPath)}`)
|
|
1781
|
+
// HIV-P14b. A BLIND RECREATE IS NOT A RACE, and the empty-base merge below corrupts it.
|
|
1782
|
+
//
|
|
1783
|
+
// The dichotomy above has a third case: the path appeared on OUR disk after we started AND
|
|
1784
|
+
// the room already had it before we joined. The working set is bounded, nothing touched
|
|
1785
|
+
// that file, so the agent could not see it, concluded it did not exist, and wrote one.
|
|
1786
|
+
//
|
|
1787
|
+
// Measured 21 Sep 2026, traced through the running product. Agent B, holding 8 of a room's
|
|
1788
|
+
// 41 files, wrote mod039.js:
|
|
1789
|
+
// mod039.js origin=remote synced=true docLen=22 diskLen=39 hasBase=false
|
|
1790
|
+
// inManifest=true preexisting=false -> BRANCH=no-base-block
|
|
1791
|
+
// It lands exactly here, `preexisting` is false, and the empty-base merge unions two whole
|
|
1792
|
+
// files into agent A's copy:
|
|
1793
|
+
// export const v39 = 39
|
|
1794
|
+
// export const v39 = "FROM SCRATCH BY B"
|
|
1795
|
+
// Two const declarations of the same name. Invalid code, in someone else's folder, from B
|
|
1796
|
+
// doing the only thing available to it.
|
|
1797
|
+
//
|
|
1798
|
+
// The union is right for what the comment above describes -- two authors adding DIFFERENT
|
|
1799
|
+
// blocks to a file they both just created. It is wrong for two independent whole-file
|
|
1800
|
+
// creations of the same module, which is what a blind recreate always is.
|
|
1801
|
+
//
|
|
1802
|
+
// `roomAtJoin` is the discriminator `preexisting` cannot be: a path in it existed in the
|
|
1803
|
+
// ROOM before we did, however new it is on our disk. The `roomAtJoin &&` guard is
|
|
1804
|
+
// load-bearing -- before first sync it is null, and treating null as an empty room would
|
|
1805
|
+
// classify every joiner's first creation as a race.
|
|
1806
|
+
//
|
|
1807
|
+
// Adopt the room's copy, because it is the real file and the agent needs it to work from,
|
|
1808
|
+
// and snapshot the local draft so nothing the agent wrote is lost. Then say so loudly: the
|
|
1809
|
+
// entire failure was the agent not knowing the file was already there.
|
|
1810
|
+
if (roomAtJoin && roomAtJoin.has(relPath) && !preexisting.has(relPath)) {
|
|
1811
|
+
captureSnapshot(relPath, disk, name, {
|
|
1812
|
+
force: true, kind: 'manual',
|
|
1813
|
+
label: `local draft preserved — ${relPath} already existed in the room before this client joined`,
|
|
1814
|
+
})
|
|
1815
|
+
bases.set(relPath, docText); forkBases.set(relPath, docText); shadow.set(relPath, docText)
|
|
1816
|
+
if (docText !== disk) writeToDisk(relPath, docText)
|
|
1817
|
+
try {
|
|
1818
|
+
say(`⚠ ${relPath} ALREADY EXISTED IN THE ROOM before I joined — I had not fetched it, so it was ` +
|
|
1819
|
+
`written here from scratch. The room's copy is now on disk and my draft was snapshotted. ` +
|
|
1820
|
+
`This folder holds a bounded working set: a file you cannot see may still exist, so read or ` +
|
|
1821
|
+
`claim the path before creating it. hive_status reports filesHeld of roomFiles.`)
|
|
1822
|
+
} catch { }
|
|
1823
|
+
if (process.env.HIVE_P14_DEBUG) log(`[P14] BRANCH=blind-recreate-refused ${relPath}`)
|
|
1824
|
+
return
|
|
1825
|
+
}
|
|
1495
1826
|
if (!preexisting.has(relPath)) {
|
|
1496
1827
|
// Terminate both sides before merging. With an empty base there is no shared
|
|
1497
1828
|
// trailing context, so a side whose last line lacks a newline gets its final
|
|
@@ -1499,6 +1830,33 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1499
1830
|
// which is worse than failing -- it reads as corruption to whoever opens it.
|
|
1500
1831
|
const nl = (s) => (s === '' || s.endsWith('\n') ? s : s + '\n')
|
|
1501
1832
|
const r0 = icrMerge3('', nl(disk), nl(docText), relPath)
|
|
1833
|
+
// HIV-P17: SILENT COLLISION DETECTION on genesis-publish creation race.
|
|
1834
|
+
// The normal merge path (below, ~line 1880) runs fusionReason() to catch merges
|
|
1835
|
+
// that broke valid code or duplicated structure. This creation-race path skipped
|
|
1836
|
+
// that check entirely — a dead-heat race could silently fuse two whole-file
|
|
1837
|
+
// rewrites of the same module and nobody was told. Now we run the same check:
|
|
1838
|
+
// if both inputs parsed but the merged result does not, or if the merge duplicated
|
|
1839
|
+
// structure, refuse it — keep both sides intact and announce loudly.
|
|
1840
|
+
if (!r0.conflict) {
|
|
1841
|
+
const why = fusionReason(r0.text, nl(disk), nl(docText), supports(relPath) ? languageFor(relPath) : null)
|
|
1842
|
+
if (why) {
|
|
1843
|
+
conflicted.add(relPath)
|
|
1844
|
+
// Do NOT write the fused result to doc or disk. The doc already holds docText
|
|
1845
|
+
// (the room's copy) and disk holds the local draft — both are intact. Setting
|
|
1846
|
+
// bases to docText means the next normal edit will merge against the room's copy,
|
|
1847
|
+
// which is the same recovery path the normal-merge refusal uses.
|
|
1848
|
+
bases.set(relPath, docText); forkBases.set(relPath, docText); shadow.set(relPath, docText)
|
|
1849
|
+
if (!manifest.has(relPath)) manifest.set(relPath, 1)
|
|
1850
|
+
if (icrWarned.get(relPath) !== why) {
|
|
1851
|
+
icrWarned.set(relPath, why)
|
|
1852
|
+
log(`[${name}] !! HIV-P17 REFUSED a fused creation-race merge of ${relPath}: ${why}`)
|
|
1853
|
+
try {
|
|
1854
|
+
say(`[!] @all ${relPath}: a creation-race 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. Someone must reconcile them deliberately; a plain file write will merge against the other version again, hive_edit replaces it outright.`)
|
|
1855
|
+
} catch { /* the log still carries it */ }
|
|
1856
|
+
}
|
|
1857
|
+
return
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1502
1860
|
if (r0.text !== docText) fe.doc.transact(() => applyDiff(yt, r0.text))
|
|
1503
1861
|
if (r0.text !== disk) writeToDisk(relPath, r0.text)
|
|
1504
1862
|
if (r0.conflict) conflicted.add(relPath)
|
|
@@ -1533,14 +1891,31 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1533
1891
|
try { say(`⚠ ${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.`) } catch { }
|
|
1534
1892
|
return
|
|
1535
1893
|
}
|
|
1536
|
-
// Genuinely no ancestor (a clone-then-join
|
|
1537
|
-
//
|
|
1538
|
-
//
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
if (
|
|
1543
|
-
|
|
1894
|
+
// Genuinely no ancestor (a clone-then-join, or joining with an existing checkout).
|
|
1895
|
+
// HIV-P9: Do NOT adopt the room's copy blindly. Check provenance!
|
|
1896
|
+
// Has the room ever seen what is currently on disk?
|
|
1897
|
+
const receipts = fe && fe.ledger ? fe.ledger.toArray() : []
|
|
1898
|
+
const st = accountFor({ rel: relPath, branchText: disk, roomText: docText, receipts })
|
|
1899
|
+
|
|
1900
|
+
if (st.state === DESCENDS || st.state === IDENTICAL) {
|
|
1901
|
+
// Safe: The room has passed through our disk content, so the room's copy is a true descendant.
|
|
1902
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy at join (room copy adopted)' })
|
|
1903
|
+
writeToDisk(relPath, docText)
|
|
1904
|
+
bases.set(relPath, docText); forkBases.set(relPath, docText)
|
|
1905
|
+
if (!snappedBase.has(relPath)) { captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'adopted from room at join' }); snappedBase.add(relPath) }
|
|
1906
|
+
try { say(`↓ ${relPath} already existed in this room — ${name} adopted the room's copy. The local version before joining is saved in the file's history.`) } catch { }
|
|
1907
|
+
return
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// DIVERGED or UNPROVEN: The room has NEVER seen local disk's content, or has no proof.
|
|
1911
|
+
// Overwriting disk with docText would cause silent data loss (e.g. wiping out local fixes).
|
|
1912
|
+
// Merging from an empty base would concatenate both copies, creating invalid syntax.
|
|
1913
|
+
// Refuse adoption: keep local disk untouched, keep room doc untouched, snapshot local, and announce loudly.
|
|
1914
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: `local copy preserved at join (${st.state}: room copy refused)` })
|
|
1915
|
+
bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
1916
|
+
try {
|
|
1917
|
+
say(`⚠ ${relPath} already existed in room with different content (${st.state}) — room copy was NOT adopted. Local disk was preserved. Resolve deliberately.`)
|
|
1918
|
+
} catch { }
|
|
1544
1919
|
return
|
|
1545
1920
|
}
|
|
1546
1921
|
const base = bases.has(relPath) ? bases.get(relPath) : disk
|
|
@@ -1956,7 +2331,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1956
2331
|
let myEditing = null, myLastEdit = null
|
|
1957
2332
|
let editingClearTimer = null
|
|
1958
2333
|
const warnedAt = new Map() // file -> last time we warned about co-editing it
|
|
1959
|
-
const setUserState = () => provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, editing: myEditing || undefined, lastEdit: myLastEdit || undefined, v: PROTOCOL_VERSION })
|
|
2334
|
+
const setUserState = () => provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, editing: myEditing || undefined, lastEdit: myLastEdit || undefined, v: PROTOCOL_VERSION, passive: !!passive })
|
|
1960
2335
|
function markEditing(relPath) {
|
|
1961
2336
|
myEditing = { file: relPath, at: Date.now() }
|
|
1962
2337
|
myLastEdit = myEditing
|
|
@@ -2058,16 +2433,106 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2058
2433
|
}
|
|
2059
2434
|
if (syncFiles) provider.awareness.on('change', () => { renderMembers(); renderClaims(); checkVersions() })
|
|
2060
2435
|
|
|
2436
|
+
// HIV-P8: A FILE THAT WILL NEVER LEAVE THIS FOLDER, SAID OUT LOUD.
|
|
2437
|
+
//
|
|
2438
|
+
// HIV-P3 closed the genesis-claim hole at CLAIM time by classifying the claim string, and it
|
|
2439
|
+
// could only ever be a classifier: at claim time a bare string is all there is, and `config`,
|
|
2440
|
+
// `hosts` and `staging` are indistinguishable from ticket labels. At WRITE time there is no
|
|
2441
|
+
// ambiguity left — the file is on disk and isIgnored() is exact. So this is the backstop for
|
|
2442
|
+
// everything the classifier could not decide.
|
|
2443
|
+
//
|
|
2444
|
+
// IT CANNOT REFUSE. The bytes are already written by the time anyone knows, so this is
|
|
2445
|
+
// structurally a warning — and a warning is exactly what every born-invisible incident has
|
|
2446
|
+
// already scrolled past. That is why the claimed case opens an OBLIGATION instead of logging:
|
|
2447
|
+
// an obligation rides hive_wait, backs off, escalates to STALE and shows on the board. It is
|
|
2448
|
+
// un-ignorable by construction and costs nothing while nobody is idle.
|
|
2449
|
+
//
|
|
2450
|
+
// THE TWO GATES ARE THE WHOLE DESIGN, and the numbers are why. On a real checkout there are 256
|
|
2451
|
+
// ignored files. Warning about all of them would open 256 debts on join, each closable only by
|
|
2452
|
+
// its author, and the ledger would be unusable inside one join — the same flooding we spent this
|
|
2453
|
+
// morning clearing forty entries of. So:
|
|
2454
|
+
// CLAIMED the author declared they were working on this path. That is their own stated
|
|
2455
|
+
// intent, already in the CRDT — not a guess about the filename.
|
|
2456
|
+
// NOT PREEXISTING it appeared during this session, rather than having sat there for a week.
|
|
2457
|
+
// Neither alone is sufficient: without the claim gate, 256; without the other, every long-lived
|
|
2458
|
+
// ignored file re-fires on every join.
|
|
2459
|
+
//
|
|
2460
|
+
// THE UNCLAIMED CASE GETS ONE LINE AND NO LEDGER ENTRY. The room rule is CLAIM BEFORE YOU WRITE,
|
|
2461
|
+
// so an unclaimed write is outside the protocol and does not get to consume the ledger. I am not
|
|
2462
|
+
// confident this tier earns its place — a chat line is precisely the signal these incidents
|
|
2463
|
+
// scroll past — but deleting the case entirely is worse than announcing it cheaply.
|
|
2464
|
+
const ENGINE_ID = 'hivecode'
|
|
2465
|
+
const invisibleWarned = new Map() // relPath -> obligation id, or null for the ambient case
|
|
2466
|
+
|
|
2467
|
+
function noticeBornInvisible(ignoredRel) {
|
|
2468
|
+
// DISCHARGE FIRST. A file that has gone away takes its debt with it: the question "this will
|
|
2469
|
+
// never sync" stops being a question when there is nothing left to sync. Left open, the ledger
|
|
2470
|
+
// fills with debts about paths that no longer exist, which is the noise the three-state
|
|
2471
|
+
// lifecycle exists to prevent. Its expiry cannot help here — that only covers names never seen
|
|
2472
|
+
// in the room, and this path was seen, on disk, by us.
|
|
2473
|
+
for (const [r, id] of [...invisibleWarned]) {
|
|
2474
|
+
if (ignoredRel.has(r)) continue
|
|
2475
|
+
if (id) { try { OB.discharge(doc.getMap('obligations'), { id, by: ENGINE_ID }) } catch { } }
|
|
2476
|
+
invisibleWarned.delete(r)
|
|
2477
|
+
}
|
|
2478
|
+
for (const r of ignoredRel) {
|
|
2479
|
+
if (SKIP.has(r)) continue // control files are ours and are supposed to be invisible
|
|
2480
|
+
if (preexisting.has(r)) continue // it was already here; nobody did this during the session
|
|
2481
|
+
if (invisibleWarned.has(r)) continue // once per path, not once per scan tick
|
|
2482
|
+
const holder = coordinator.sense(r)
|
|
2483
|
+
if (holder && holder.by) {
|
|
2484
|
+
let id = null
|
|
2485
|
+
try {
|
|
2486
|
+
const rec = OB.open(doc.getMap('obligations'), {
|
|
2487
|
+
from: ENGINE_ID,
|
|
2488
|
+
to: holder.by,
|
|
2489
|
+
text: `${r} is on disk and claimed by you, but it is ignored by a .gitignore here — ` +
|
|
2490
|
+
`no sync connection is opened for it, so nothing written to it will ever reach the room. ` +
|
|
2491
|
+
`Add it to the allow-list, or confirm the work is deliberately local.`,
|
|
2492
|
+
absent: false,
|
|
2493
|
+
neverSeen: false,
|
|
2494
|
+
})
|
|
2495
|
+
id = rec && rec.id
|
|
2496
|
+
} catch { }
|
|
2497
|
+
invisibleWarned.set(r, id)
|
|
2498
|
+
try {
|
|
2499
|
+
say(`⛔ ${r} is claimed and on disk, but it is ignored by a .gitignore here — its bytes will never leave this folder.`)
|
|
2500
|
+
} catch { }
|
|
2501
|
+
} else {
|
|
2502
|
+
invisibleWarned.set(r, null)
|
|
2503
|
+
try {
|
|
2504
|
+
say(`· ${r} was created here but is ignored by a .gitignore — it stays local, and no other agent will see it.`)
|
|
2505
|
+
} catch { }
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2061
2510
|
function scan() {
|
|
2062
|
-
const
|
|
2511
|
+
const skippedFulls = []
|
|
2512
|
+
const diskFulls = walk(ROOT, [], skippedFulls)
|
|
2063
2513
|
const diskRel = new Set(diskFulls.map(rel))
|
|
2514
|
+
const ignoredRel = new Set(skippedFulls.map(rel))
|
|
2064
2515
|
// Snapshot the starting file set BEFORE the first reconcile pass touches anything.
|
|
2065
2516
|
// Everything here pre-dates our participation; anything that shows up later was
|
|
2066
2517
|
// authored while we were connected. See `preexisting`.
|
|
2067
2518
|
// The ancestor must be loaded BEFORE any path can reach reconcile(), and this is the
|
|
2068
2519
|
// one place guaranteed to run first exactly once. Loading it later would mean the first
|
|
2069
2520
|
// scan's reconciles see no ancestor — precisely the bug this is here to close.
|
|
2070
|
-
|
|
2521
|
+
//
|
|
2522
|
+
// HIV-P8: THE UNION IS NOT A DETAIL. `preexisting` used to be seeded from diskRel alone, and
|
|
2523
|
+
// diskRel comes from walk(), which dropped ignored files — so `preexisting.has(r)` was
|
|
2524
|
+
// permanently FALSE for every ignored path, whether it was created a second ago or had been
|
|
2525
|
+
// sitting there for a week. Any check phrased as "ignored AND not preexisting" therefore
|
|
2526
|
+
// reduced to "ignored", and a client joining a real checkout would have opened an obligation
|
|
2527
|
+
// for each of its 256 ignored files, about files nobody touched. Seeding the union is what
|
|
2528
|
+
// makes "not preexisting" mean what it says: authored during this session.
|
|
2529
|
+
if (!firstScanDone) {
|
|
2530
|
+
loadPriorShadow()
|
|
2531
|
+
for (const r of diskRel) preexisting.add(r)
|
|
2532
|
+
for (const r of ignoredRel) preexisting.add(r)
|
|
2533
|
+
firstScanDone = true
|
|
2534
|
+
}
|
|
2535
|
+
noticeBornInvisible(ignoredRel)
|
|
2071
2536
|
for (const full of diskFulls) {
|
|
2072
2537
|
const r = rel(full)
|
|
2073
2538
|
if (SKIP.has(r)) continue
|
|
@@ -2104,7 +2569,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2104
2569
|
// the sweep achieved nothing: the first measurement of this cap showed a client with
|
|
2105
2570
|
// cap 8 holding 30 file docs, every one of them opened right here. A path we decline
|
|
2106
2571
|
// to open now is not lost; it opens when something touches it.
|
|
2107
|
-
if (fileDocs.size <
|
|
2572
|
+
if (fileDocs.size < maxOpen()) openFile(key)
|
|
2108
2573
|
}
|
|
2109
2574
|
})
|
|
2110
2575
|
})
|
|
@@ -2127,6 +2592,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2127
2592
|
|
|
2128
2593
|
provider.on('sync', (s) => {
|
|
2129
2594
|
if (!s || !syncFiles) return
|
|
2595
|
+
// HIV-P14b. Snapshot what the room already had, once. Measured: the manifest IS populated at
|
|
2596
|
+
// this point -- a fresh joiner's observer fires with 13 of 13 entries before the sync event,
|
|
2597
|
+
// so reading it here is sound. (That was checked because the first version of this guard was
|
|
2598
|
+
// wrongly assumed to be failing for this reason.)
|
|
2599
|
+
if (!roomAtJoin) {
|
|
2600
|
+
roomAtJoin = new Set()
|
|
2601
|
+
try { for (const k of manifest.keys()) roomAtJoin.add(k) } catch { }
|
|
2602
|
+
}
|
|
2130
2603
|
writeToDisk(RULES_FILE, HIVE_RULES_TEXT) // the law is always present in the room
|
|
2131
2604
|
writeToDisk(AGENTS_FILE, AGENT_MANUAL_TEXT) // so an agent opening this folder learns how to join + behave
|
|
2132
2605
|
// Connect to every file I'm granted AND have not excluded; each file-room's sync pulls
|
|
@@ -2145,16 +2618,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2145
2618
|
//
|
|
2146
2619
|
// Files that exist on THIS client's disk come first: they are the ones that need
|
|
2147
2620
|
// reconciling, and a path we have no local copy of can wait until something asks for it.
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2621
|
+
//
|
|
2622
|
+
// If /health has not said yet which transport this relay speaks, the sweep opens what is safe
|
|
2623
|
+
// on either (the per-file bound) now and the rest the moment it answers -- holding every file
|
|
2624
|
+
// back on one HTTP round trip would delay the join for nothing.
|
|
2625
|
+
const sweep = (final) => {
|
|
2626
|
+
const openable = [...manifest.keys()].filter((k) => canOpen(k) && !SKIP.has(k) && !isIgnored(k))
|
|
2627
|
+
const localFirst = openable.sort((a, b) => {
|
|
2628
|
+
const la = fs.existsSync(path.join(ROOT, a)) ? 0 : 1
|
|
2629
|
+
const lb = fs.existsSync(path.join(ROOT, b)) ? 0 : 1
|
|
2630
|
+
return la - lb
|
|
2631
|
+
})
|
|
2632
|
+
const cap = maxOpen()
|
|
2633
|
+
for (const key of localFirst.slice(0, cap)) openFile(key)
|
|
2634
|
+
if (final && localFirst.length > cap) {
|
|
2635
|
+
log(`[${name}] ${localFirst.length} files in this room, holding ${cap} open (HIVE_MAX_OPEN_FILES). The rest sync when touched.`)
|
|
2636
|
+
}
|
|
2157
2637
|
}
|
|
2638
|
+
const decided = fileTransport.mode !== 'detecting'
|
|
2639
|
+
sweep(decided)
|
|
2640
|
+
if (!decided) fileTransport.ready.then(() => { if (!stopped) sweep(true) })
|
|
2158
2641
|
if (board.size) renderBoard()
|
|
2159
2642
|
if (chat.length) renderChat()
|
|
2160
2643
|
if (tasks.size) renderTasks()
|
|
@@ -2162,6 +2645,37 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2162
2645
|
renderClaims() // unconditional: "nothing is claimed" is the answer an arriving agent needs most
|
|
2163
2646
|
scan()
|
|
2164
2647
|
log(`[${name}] folder sync active on ${ROOT} (room "${room}") as ${kind}. ${manifest.size} files.`)
|
|
2648
|
+
// HIV-P14. SAY THE GAP OUT LOUD, ONCE, AT JOIN.
|
|
2649
|
+
//
|
|
2650
|
+
// The working set is bounded on purpose, and the documented trade is that "a file outside it
|
|
2651
|
+
// does not arrive until something touches it". For a human reading a folder that is fine. For
|
|
2652
|
+
// an AI agent it is not, because an agent decides what exists by listing its directory: a
|
|
2653
|
+
// file it cannot see is a file it believes does not exist, and it writes one. Measured 21 Sep
|
|
2654
|
+
// 2026, a 40-file room and MAX_OPEN=8: the joiner held 7 files, reported "filesSynced 8/8",
|
|
2655
|
+
// said nothing, and its agent recreated a file the room already had.
|
|
2656
|
+
//
|
|
2657
|
+
// Nothing here changes the cap. It tells the agent the number it needs in order to ASK,
|
|
2658
|
+
// which is the one thing that makes "until something touches it" true.
|
|
2659
|
+
//
|
|
2660
|
+
// Said only when the bound really leaves files out. Below it a missing file is in flight (or
|
|
2661
|
+
// refused, which noteRefusals reports with the relay's reason) -- not missing on purpose -- and
|
|
2662
|
+
// one-socket mode holds 4096 files, so an ordinary room never hears this. Timed from the
|
|
2663
|
+
// moment the transport is known, because the bound is not known before that.
|
|
2664
|
+
fileTransport.ready.then(() => setTimeout(() => {
|
|
2665
|
+
try {
|
|
2666
|
+
const want = [...manifest.keys()].filter((r) => canOpen(r) && !SKIP.has(r))
|
|
2667
|
+
const have = want.filter((r) => fs.existsSync(path.join(ROOT, r)))
|
|
2668
|
+
const short = want.length - have.length
|
|
2669
|
+
if (short <= 0 || want.length <= maxOpen() || stopped) return
|
|
2670
|
+
log(`[${name}] !! THIS FOLDER IS INCOMPLETE ON PURPOSE. The room has ${want.length} files; ` +
|
|
2671
|
+
`${have.length} are on disk here and ${short} are NOT. The working set is bounded to ` +
|
|
2672
|
+
`${maxOpen()} open files (HIVE_MAX_OPEN_FILES), because ` +
|
|
2673
|
+
(fileTransport.mode === 'mux' ? `every open file costs relay memory. ` : `one socket is opened per file and a relay admits a limited number. `) +
|
|
2674
|
+
`A missing file EXISTS — it simply has not been fetched. ` +
|
|
2675
|
+
`Do not treat it as absent and do not create it: reading or claiming the path fetches it. ` +
|
|
2676
|
+
`hive_status reports filesHeld of roomFiles, and hive_read_board lists what the room holds.`)
|
|
2677
|
+
} catch { }
|
|
2678
|
+
}, 4000).unref?.())
|
|
2165
2679
|
if (!scanTimer) {
|
|
2166
2680
|
startWatch()
|
|
2167
2681
|
scanTimer = setInterval(scan, watcher ? 2000 : 400) // watch handles fast path; scan is the net
|
|
@@ -2265,7 +2779,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2265
2779
|
isPaused, // is a participant currently paused?
|
|
2266
2780
|
controls, // the live control map
|
|
2267
2781
|
// --- hive coordination: claim a region BEFORE editing (collision prevention) ---
|
|
2268
|
-
|
|
2782
|
+
// HIV-P18: a claim is a promise about BYTES, so granting one over bytes the claimer cannot
|
|
2783
|
+
// see is the same false success we shut in the outbound direction (HIV-27: a claim whose
|
|
2784
|
+
// writes could never leave). The claim itself is unchanged -- callers and every existing
|
|
2785
|
+
// suite still get the same boolean -- but it now also starts the fetch the README has been
|
|
2786
|
+
// promising, so an agent that claims a path it does not hold actually receives it instead of
|
|
2787
|
+
// finding an empty directory and creating the file from scratch.
|
|
2788
|
+
claim: (region, intent) => {
|
|
2789
|
+
const r = String(region), i = String(intent || 'edit')
|
|
2790
|
+
myIntents.set(r, i)
|
|
2791
|
+
const held = coordinator.acquire(r, i)
|
|
2792
|
+
// Fire-and-forget on purpose: claim's contract is synchronous and changing that would
|
|
2793
|
+
// break every caller. Use fetch(path) directly when you need to WAIT for the bytes.
|
|
2794
|
+
if (held) { try { fetchFile(r).catch(() => {}) } catch { } }
|
|
2795
|
+
return held
|
|
2796
|
+
}, // true iff you hold it (my intent recorded either way — it's what my receipts sign)
|
|
2797
|
+
// HIV-P18: bring a file the room has but this folder does not. Resolves when the BYTES have
|
|
2798
|
+
// landed, and distinguishes "outside the working set" (fetchable) from "not in the room at
|
|
2799
|
+
// all" (nothing will ever arrive) -- two states that look identical from an empty directory
|
|
2800
|
+
// and have opposite remedies.
|
|
2801
|
+
fetch: fetchFile,
|
|
2269
2802
|
// RELEASE IS A HANDOFF, AND A HANDOFF CAN BE REFUSED.
|
|
2270
2803
|
//
|
|
2271
2804
|
// Until the room sets `gate:cmd`, this behaves exactly as it always did and returns
|
|
@@ -2367,7 +2900,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2367
2900
|
const u = s.user
|
|
2368
2901
|
if (!u || !u.name) continue
|
|
2369
2902
|
const prev = seen.get(u.name) || {}
|
|
2370
|
-
seen.set(u.name, { ...prev, ...u, editing: u.editing || prev.editing, owner: u.owner || prev.owner })
|
|
2903
|
+
seen.set(u.name, { ...prev, ...u, editing: u.editing || prev.editing, owner: u.owner || prev.owner, passive: !!(u.passive || prev.passive) })
|
|
2371
2904
|
}
|
|
2372
2905
|
return [...seen.values()]
|
|
2373
2906
|
},
|
|
@@ -2386,18 +2919,51 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2386
2919
|
// whose own socket the relay refused. They are not slow, they are not arriving, and
|
|
2387
2920
|
// until they do they read as empty. filesSynced alone shows a shortfall but not a cause.
|
|
2388
2921
|
const notSyncing = [...refusedFiles.keys()].filter((r) => { const e = fileDocs.get(r); return e && !e.synced })
|
|
2922
|
+
// HIV-P14. THE FULL-HOUSE LIE.
|
|
2923
|
+
//
|
|
2924
|
+
// `files` and `filesSynced` both counted OPEN docs, so a client holding eight of the
|
|
2925
|
+
// room's forty files reported "filesSynced 8/8" -- a complete set. Measured 21 Sep 2026:
|
|
2926
|
+
// a second agent joined a 40-file room, received 7, stayed at 7 for a full minute, and
|
|
2927
|
+
// reported 8/8. Its manifest listed all forty the whole time.
|
|
2928
|
+
//
|
|
2929
|
+
// The bounded working set is deliberate and correct -- one socket per file against a 512
|
|
2930
|
+
// ceiling is the wall, and tests/open-file-cap-test.mjs documents the trade rather than
|
|
2931
|
+
// hiding it: "a file outside the working set does not arrive until something touches it."
|
|
2932
|
+
// What that header does not cover is that FOR AN AGENT, NOTHING EVER TOUCHES IT. An agent
|
|
2933
|
+
// decides what exists by reading its folder; a file it cannot see is a file it believes
|
|
2934
|
+
// does not exist, so it writes one from scratch. Measured consequence, same probe: agent B
|
|
2935
|
+
// created mod039.js, and agent A's copy ended up holding two const declarations of the
|
|
2936
|
+
// same name -- invalid code, in someone else's folder, from B doing the only reasonable
|
|
2937
|
+
// thing available to it.
|
|
2938
|
+
//
|
|
2939
|
+
// So the trade stays and the reporting changes. `files` keeps meaning open docs, because
|
|
2940
|
+
// tests/open-file-cap-test.mjs:71 pins it as the proof the cap is respected. What is added
|
|
2941
|
+
// is the pair that was missing: what the ROOM has, and what this client actually HOLDS.
|
|
2942
|
+
const roomFiles = (() => { try { return [...manifest.keys()].filter((r) => canOpen(r)).length } catch { return 0 } })()
|
|
2943
|
+
const held = (() => {
|
|
2944
|
+
let n = 0
|
|
2945
|
+
try { for (const r of manifest.keys()) { if (canOpen(r) && fs.existsSync(path.join(ROOT, r))) n++ } } catch { }
|
|
2946
|
+
return n
|
|
2947
|
+
})()
|
|
2389
2948
|
return {
|
|
2390
2949
|
connected: !!provider.wsconnected,
|
|
2391
2950
|
connecting: !!provider.wsconnecting,
|
|
2392
2951
|
files: fileDocs.size,
|
|
2393
2952
|
filesSynced: synced,
|
|
2953
|
+
// The honest pair. `filesHeld` of `roomFiles` is the number a joiner needs, and the one
|
|
2954
|
+
// that says 7 of 40 where filesSynced said 8 of 8.
|
|
2955
|
+
roomFiles,
|
|
2956
|
+
filesHeld: held,
|
|
2957
|
+
filesMissing: Math.max(0, roomFiles - held),
|
|
2394
2958
|
notSyncing: notSyncing.length,
|
|
2395
2959
|
notSyncingFiles: notSyncing.slice(0, 20),
|
|
2396
2960
|
// The working set and its bound. `openCap` x agents must stay under the relay's
|
|
2397
2961
|
// maxConnsTotal, and `evicted` climbing steadily means the cap is too small for
|
|
2398
2962
|
// how this room actually works — both are things an operator can only act on if
|
|
2399
2963
|
// they can see them.
|
|
2400
|
-
openCap:
|
|
2964
|
+
openCap: maxOpen(),
|
|
2965
|
+
// 'mux' = every file on this client's one socket; 'socket' = a socket per file.
|
|
2966
|
+
transport: fileTransport.mode,
|
|
2401
2967
|
evicted,
|
|
2402
2968
|
}
|
|
2403
2969
|
},
|
|
@@ -2406,6 +2972,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2406
2972
|
if (debounce) clearTimeout(debounce)
|
|
2407
2973
|
if (editingClearTimer) clearTimeout(editingClearTimer)
|
|
2408
2974
|
try { watcher && watcher.close() } catch { }
|
|
2975
|
+
stopped = true
|
|
2976
|
+
// The shared socket first: closing it ends every channel at once on the relay, instead of
|
|
2977
|
+
// one CLOSE frame per file down a socket that is about to go anyway.
|
|
2978
|
+
try { fileTransport.stop() } catch { }
|
|
2409
2979
|
for (const r of [...fileDocs.keys()]) closeFile(r) // tear down every per-file provider
|
|
2410
2980
|
try { provider.awareness.setLocalState(null) } catch { } // announce departure now (don't wait for timeout)
|
|
2411
2981
|
try { provider.destroy() } catch { }
|