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/hive-busy.js
CHANGED
|
@@ -1,88 +1,88 @@
|
|
|
1
|
-
// SELF-DECLARED BUSY: "I am mid-task, do not expect an answer this second."
|
|
2
|
-
//
|
|
3
|
-
// WHY THIS IS STATE AND NOT A MESSAGE. The obvious design is an auto-reply: someone @s you
|
|
4
|
-
// while you are working, you post "busy, will get back to you". Eight agents and one broadcast
|
|
5
|
-
// is eight of those, each of which is itself a message in the room; if the acknowledgements are
|
|
6
|
-
// addressed they wake the sender, who is also busy, who acknowledges back. That is a spam
|
|
7
|
-
// channel with extra steps, and this room has already been through it once -- a conflict
|
|
8
|
-
// notification loop put 56 copies of one line into chat and 306 of 2,314 lines were that loop.
|
|
9
|
-
//
|
|
10
|
-
// So nobody announces anything. The busy agent sets a flag and keeps working, and the
|
|
11
|
-
// INFORMATION IS HANDED TO THE SENDER AT SEND TIME instead: hive_say reports the recipient's
|
|
12
|
-
// state in its own return value. Same fact, delivered faster than a reply could arrive, and it
|
|
13
|
-
// adds zero messages to the room. The interrupted agent is never made to interrupt itself in
|
|
14
|
-
// order to be polite.
|
|
15
|
-
//
|
|
16
|
-
// The `controls` map is deliberately not reused. That is mission control -- a HUMAN sets
|
|
17
|
-
// running/paused there -- and an agent writing its own state into the same entry would clobber
|
|
18
|
-
// a human's pause with a self-report. Different author, different authority, different map.
|
|
19
|
-
//
|
|
20
|
-
// TTL, for the same reason claims have one: an agent that dies mid-task must not leave a
|
|
21
|
-
// permanent "busy" flag behind that quietly teaches the whole room to stop talking to it. A
|
|
22
|
-
// stale flag is worse than no flag, because everyone believes it.
|
|
23
|
-
|
|
24
|
-
/** Default life of a busy flag. Long enough for real work, short enough that a crash clears. */
|
|
25
|
-
export const DEFAULT_TTL = 10 * 60 * 1000
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* The ceiling, and it is the load-bearing one.
|
|
29
|
-
*
|
|
30
|
-
* The whole point of a TTL here is that a crashed agent cannot leave the room believing it is
|
|
31
|
-
* permanently busy. Without a cap, `ttl: Infinity` -- or a caller passing milliseconds where it
|
|
32
|
-
* meant minutes -- reinstates exactly the failure the TTL exists to prevent, and it fails
|
|
33
|
-
* silently: everyone simply stops expecting an answer, forever, and nothing looks broken.
|
|
34
|
-
*
|
|
35
|
-
* An hour is past the point where "busy" is still true. Beyond that the agent is stuck, and the
|
|
36
|
-
* room is better off treating it as free and finding out.
|
|
37
|
-
*/
|
|
38
|
-
export const MAX_TTL = 60 * 60 * 1000
|
|
39
|
-
|
|
40
|
-
/** An entry as stored: { what, since, ttl }. `since` is unix ms, matching hive-coord's claims. */
|
|
41
|
-
|
|
42
|
-
/** Live, or expired/absent? Pure, so the TTL is testable on a virtual clock. */
|
|
43
|
-
export function isBusy(entry, now = Date.now()) {
|
|
44
|
-
if (!entry || typeof entry !== 'object') return false
|
|
45
|
-
const since = Number(entry.since) || 0
|
|
46
|
-
// Clamped on READ as well as on write, and this is the side that matters: the entry arrives
|
|
47
|
-
// over the wire from another client, which may be an older build, a different version, or
|
|
48
|
-
// simply wrong. Trusting a ttl we did not create is how one peer gets to pin the whole room.
|
|
49
|
-
const ttl = Math.min(MAX_TTL, Math.max(1000, Number(entry.ttl) || DEFAULT_TTL))
|
|
50
|
-
return since > 0 && now - since < ttl
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Build an entry. `what` is free text -- what they are doing, for the sender to read. */
|
|
54
|
-
export function makeBusy(what, { ttl = DEFAULT_TTL, now = Date.now() } = {}) {
|
|
55
|
-
// Clamped at BOTH ends. 0/NaN/absent falls back to the default; anything above the ceiling is
|
|
56
|
-
// brought down to it, so no caller -- and no bad arithmetic in a caller -- can mint a flag
|
|
57
|
-
// that outlives the work it describes.
|
|
58
|
-
const want = Number(ttl) || DEFAULT_TTL
|
|
59
|
-
return { what: String(what || 'working').slice(0, 200), since: now, ttl: Math.min(MAX_TTL, Math.max(1000, want)) }
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** How long they have been at it, in words a sender can act on. */
|
|
63
|
-
export function busyFor(entry, now = Date.now()) {
|
|
64
|
-
const ms = Math.max(0, now - (Number(entry && entry.since) || now))
|
|
65
|
-
const s = Math.round(ms / 1000)
|
|
66
|
-
if (s < 90) return s + 's'
|
|
67
|
-
const m = Math.round(s / 60)
|
|
68
|
-
return m < 90 ? m + 'm' : Math.round(m / 60) + 'h'
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* The line hive_say hands back to a sender about one recipient.
|
|
73
|
-
*
|
|
74
|
-
* Returns '' when there is nothing worth saying, so callers can drop it with a falsy check --
|
|
75
|
-
* silence is the right output for "they are here and free", which is the common case and does
|
|
76
|
-
* not need a sentence.
|
|
77
|
-
*
|
|
78
|
-
* @param {string} name the recipient
|
|
79
|
-
* @param {object|null} entry their busy entry, if any
|
|
80
|
-
* @param {boolean} present are they actually in the room right now
|
|
81
|
-
*/
|
|
82
|
-
export function describeRecipient(name, entry, present, now = Date.now()) {
|
|
83
|
-
// ABSENCE OUTRANKS BUSY, and it is the more useful of the two: a message to someone who left
|
|
84
|
-
// is not delayed, it is unread forever, and the sender is the only one who can notice.
|
|
85
|
-
if (!present) return `@${name} is NOT in the room — nobody will read that. hive_members lists who is here.`
|
|
86
|
-
if (isBusy(entry, now)) return `@${name} has been busy ${busyFor(entry, now)} (${entry.what}) — expect a delay, they are not idle waiting on you.`
|
|
87
|
-
return ''
|
|
88
|
-
}
|
|
1
|
+
// SELF-DECLARED BUSY: "I am mid-task, do not expect an answer this second."
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS STATE AND NOT A MESSAGE. The obvious design is an auto-reply: someone @s you
|
|
4
|
+
// while you are working, you post "busy, will get back to you". Eight agents and one broadcast
|
|
5
|
+
// is eight of those, each of which is itself a message in the room; if the acknowledgements are
|
|
6
|
+
// addressed they wake the sender, who is also busy, who acknowledges back. That is a spam
|
|
7
|
+
// channel with extra steps, and this room has already been through it once -- a conflict
|
|
8
|
+
// notification loop put 56 copies of one line into chat and 306 of 2,314 lines were that loop.
|
|
9
|
+
//
|
|
10
|
+
// So nobody announces anything. The busy agent sets a flag and keeps working, and the
|
|
11
|
+
// INFORMATION IS HANDED TO THE SENDER AT SEND TIME instead: hive_say reports the recipient's
|
|
12
|
+
// state in its own return value. Same fact, delivered faster than a reply could arrive, and it
|
|
13
|
+
// adds zero messages to the room. The interrupted agent is never made to interrupt itself in
|
|
14
|
+
// order to be polite.
|
|
15
|
+
//
|
|
16
|
+
// The `controls` map is deliberately not reused. That is mission control -- a HUMAN sets
|
|
17
|
+
// running/paused there -- and an agent writing its own state into the same entry would clobber
|
|
18
|
+
// a human's pause with a self-report. Different author, different authority, different map.
|
|
19
|
+
//
|
|
20
|
+
// TTL, for the same reason claims have one: an agent that dies mid-task must not leave a
|
|
21
|
+
// permanent "busy" flag behind that quietly teaches the whole room to stop talking to it. A
|
|
22
|
+
// stale flag is worse than no flag, because everyone believes it.
|
|
23
|
+
|
|
24
|
+
/** Default life of a busy flag. Long enough for real work, short enough that a crash clears. */
|
|
25
|
+
export const DEFAULT_TTL = 10 * 60 * 1000
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The ceiling, and it is the load-bearing one.
|
|
29
|
+
*
|
|
30
|
+
* The whole point of a TTL here is that a crashed agent cannot leave the room believing it is
|
|
31
|
+
* permanently busy. Without a cap, `ttl: Infinity` -- or a caller passing milliseconds where it
|
|
32
|
+
* meant minutes -- reinstates exactly the failure the TTL exists to prevent, and it fails
|
|
33
|
+
* silently: everyone simply stops expecting an answer, forever, and nothing looks broken.
|
|
34
|
+
*
|
|
35
|
+
* An hour is past the point where "busy" is still true. Beyond that the agent is stuck, and the
|
|
36
|
+
* room is better off treating it as free and finding out.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_TTL = 60 * 60 * 1000
|
|
39
|
+
|
|
40
|
+
/** An entry as stored: { what, since, ttl }. `since` is unix ms, matching hive-coord's claims. */
|
|
41
|
+
|
|
42
|
+
/** Live, or expired/absent? Pure, so the TTL is testable on a virtual clock. */
|
|
43
|
+
export function isBusy(entry, now = Date.now()) {
|
|
44
|
+
if (!entry || typeof entry !== 'object') return false
|
|
45
|
+
const since = Number(entry.since) || 0
|
|
46
|
+
// Clamped on READ as well as on write, and this is the side that matters: the entry arrives
|
|
47
|
+
// over the wire from another client, which may be an older build, a different version, or
|
|
48
|
+
// simply wrong. Trusting a ttl we did not create is how one peer gets to pin the whole room.
|
|
49
|
+
const ttl = Math.min(MAX_TTL, Math.max(1000, Number(entry.ttl) || DEFAULT_TTL))
|
|
50
|
+
return since > 0 && now - since < ttl
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Build an entry. `what` is free text -- what they are doing, for the sender to read. */
|
|
54
|
+
export function makeBusy(what, { ttl = DEFAULT_TTL, now = Date.now() } = {}) {
|
|
55
|
+
// Clamped at BOTH ends. 0/NaN/absent falls back to the default; anything above the ceiling is
|
|
56
|
+
// brought down to it, so no caller -- and no bad arithmetic in a caller -- can mint a flag
|
|
57
|
+
// that outlives the work it describes.
|
|
58
|
+
const want = Number(ttl) || DEFAULT_TTL
|
|
59
|
+
return { what: String(what || 'working').slice(0, 200), since: now, ttl: Math.min(MAX_TTL, Math.max(1000, want)) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How long they have been at it, in words a sender can act on. */
|
|
63
|
+
export function busyFor(entry, now = Date.now()) {
|
|
64
|
+
const ms = Math.max(0, now - (Number(entry && entry.since) || now))
|
|
65
|
+
const s = Math.round(ms / 1000)
|
|
66
|
+
if (s < 90) return s + 's'
|
|
67
|
+
const m = Math.round(s / 60)
|
|
68
|
+
return m < 90 ? m + 'm' : Math.round(m / 60) + 'h'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The line hive_say hands back to a sender about one recipient.
|
|
73
|
+
*
|
|
74
|
+
* Returns '' when there is nothing worth saying, so callers can drop it with a falsy check --
|
|
75
|
+
* silence is the right output for "they are here and free", which is the common case and does
|
|
76
|
+
* not need a sentence.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} name the recipient
|
|
79
|
+
* @param {object|null} entry their busy entry, if any
|
|
80
|
+
* @param {boolean} present are they actually in the room right now
|
|
81
|
+
*/
|
|
82
|
+
export function describeRecipient(name, entry, present, now = Date.now()) {
|
|
83
|
+
// ABSENCE OUTRANKS BUSY, and it is the more useful of the two: a message to someone who left
|
|
84
|
+
// is not delayed, it is unread forever, and the sender is the only one who can notice.
|
|
85
|
+
if (!present) return `@${name} is NOT in the room — nobody will read that. hive_members lists who is here.`
|
|
86
|
+
if (isBusy(entry, now)) return `@${name} has been busy ${busyFor(entry, now)} (${entry.what}) — expect a delay, they are not idle waiting on you.`
|
|
87
|
+
return ''
|
|
88
|
+
}
|
package/hive-coord.js
CHANGED
|
@@ -1,110 +1,110 @@
|
|
|
1
|
-
// hive-coord.js — the Hive coordination layer (v0 prototype).
|
|
2
|
-
//
|
|
3
|
-
// The bet: AI coding agents work fast and BLIND — they collide because, unlike a human
|
|
4
|
-
// team, they have no way to coordinate BEFORE they edit. Merging (ICR) cleans up after a
|
|
5
|
-
// collision. This prevents the collision in the first place.
|
|
6
|
-
//
|
|
7
|
-
// The design is a real hive: NO central controller, no conductor, no extra agent. Order
|
|
8
|
-
// emerges from each agent following one local rule against a shared medium — exactly how
|
|
9
|
-
// bees coordinate via traces in the environment (stigmergy), and how millions of Ethernet
|
|
10
|
-
// devices share one wire with no master (carrier-sense / collision-detect / backoff).
|
|
11
|
-
//
|
|
12
|
-
// The shared medium is a CRDT map living IN the codebase doc (a Y.Map in Hivecode; a plain
|
|
13
|
-
// Map in tests). Each region of work (a file, or a unit) can hold one CLAIM:
|
|
14
|
-
// { by: agentId, intent: string, at: time, ttl: ms }
|
|
15
|
-
//
|
|
16
|
-
// An agent's entire protocol — no coordinator anywhere:
|
|
17
|
-
// 1. SENSE — read the shared map. Is my target region claimed and fresh?
|
|
18
|
-
// 2. FLOW — if taken, move to open space (pick another free region). This is the
|
|
19
|
-
// emergent load-balancing: agents spread across the codebase on their own.
|
|
20
|
-
// 3. CLAIM — write my claim to the free region.
|
|
21
|
-
// 4. VERIFY — re-read (collision-detect): if a concurrent claim beat mine, BACK OFF and
|
|
22
|
-
// retry later. (Like Ethernet: listen, send, detect collision, back off.)
|
|
23
|
-
// 5. RELEASE — when done, delete my claim and (optionally) leave a "done" trace.
|
|
24
|
-
//
|
|
25
|
-
// EVAPORATION: claims carry a TTL. A crashed or stalled agent's claim simply expires, and
|
|
26
|
-
// the region frees itself — no central garbage collector, no deadlock. (The pheromone fades.)
|
|
27
|
-
//
|
|
28
|
-
// `shared` is any object with get(key)/set(key,val)/delete(key)/entries() — so the same
|
|
29
|
-
// code runs on a JS Map (tests) and a Yjs Y.Map (production). `now` is injectable so tests
|
|
30
|
-
// can drive a deterministic virtual clock.
|
|
31
|
-
|
|
32
|
-
export function makeCoordinator(shared, self, opts = {}) {
|
|
33
|
-
const ttl = opts.ttl != null ? opts.ttl : 5000
|
|
34
|
-
// A holder may RENEW its own claim, because a long edit should not lose the file mid-write.
|
|
35
|
-
// But renewal cannot be unbounded, or the lease stops being a lease. MEASURED on a 12-agent
|
|
36
|
-
// run: one agent re-claimed site/pricing.md eight times and legal/terms.md seven times and
|
|
37
|
-
// released twice, and since `claim` reset `at` on every call the 60s TTL never once elapsed.
|
|
38
|
-
// Six other agents sensed a live owner for the whole run; sixteen messages asked it to let go.
|
|
39
|
-
// `since` records the FIRST acquisition and survives renewal, so what caps a claim is total
|
|
40
|
-
// hold time rather than time since the holder last touched it.
|
|
41
|
-
const maxHold = opts.maxHold != null ? opts.maxHold : ttl * 3
|
|
42
|
-
const now = opts.now || (() => Date.now())
|
|
43
|
-
const held = (c) => now() - (c.since != null ? c.since : c.at) // total, across renewals
|
|
44
|
-
const cap = (c) => (c.maxHold != null ? c.maxHold : maxHold)
|
|
45
|
-
const overheld = (c) => !!c && held(c) >= cap(c)
|
|
46
|
-
const expired = (c) => !c || (now() - c.at) >= c.ttl || overheld(c)
|
|
47
|
-
|
|
48
|
-
// SENSE: the live, non-expired claim on a region (or null if open).
|
|
49
|
-
const sense = (region) => { const c = shared.get(region); return expired(c) ? null : c }
|
|
50
|
-
|
|
51
|
-
// Is this region open to ME? (free, expired, or already mine)
|
|
52
|
-
const free = (region) => { const c = shared.get(region); return expired(c) || c.by === self }
|
|
53
|
-
|
|
54
|
-
// A renewal keeps the original `since`; a fresh acquisition starts a new one. Claims written
|
|
55
|
-
// before `since` existed fall back to `at`, so an older room stays readable.
|
|
56
|
-
const claim = (region, intent) => {
|
|
57
|
-
const prev = shared.get(region)
|
|
58
|
-
const renewing = !!prev && prev.by === self && !expired(prev)
|
|
59
|
-
shared.set(region, {
|
|
60
|
-
by: self, intent, at: now(), ttl, maxHold,
|
|
61
|
-
since: renewing ? (prev.since != null ? prev.since : prev.at) : now(),
|
|
62
|
-
})
|
|
63
|
-
}
|
|
64
|
-
const release = (region) => { const c = shared.get(region); if (c && c.by === self) shared.delete(region) }
|
|
65
|
-
|
|
66
|
-
// ACQUIRE one region: sense → claim → verify. Returns true iff I now hold it *as far as this
|
|
67
|
-
// replica can tell*, which is a weaker statement than it looks and the caller must know it.
|
|
68
|
-
//
|
|
69
|
-
// THE VERIFY IS LOCAL, AND THAT IS ITS LIMIT. `claim()` writes into this replica's copy of the
|
|
70
|
-
// map and the re-read below sees that write immediately -- before it has reached the relay or
|
|
71
|
-
// any peer. So the check catches only a claim that had ALREADY propagated when I wrote. Fire
|
|
72
|
-
// three agents at one region in the same tick and all three are told yes; the CRDT then
|
|
73
|
-
// converges to a single winner a moment later, by which time all three have started editing.
|
|
74
|
-
// Measured 5/5 trials, three agents, every one of them granted.
|
|
75
|
-
//
|
|
76
|
-
// That is not a bug in this function -- a synchronous call cannot wait for a network -- but a
|
|
77
|
-
// lock that says yes to three callers is not a lock, so anything that is about to WRITE must
|
|
78
|
-
// use `claimSettled()` in sync.js, which re-reads the holder after the claim has round-tripped
|
|
79
|
-
// through the relay. This stays as the optimistic fast path for sensing and for flow.
|
|
80
|
-
const acquire = (region, intent) => {
|
|
81
|
-
const c = shared.get(region)
|
|
82
|
-
if (c && !expired(c) && c.by !== self) return false // sensed an owner → defer
|
|
83
|
-
// Once total hold is exhausted the region is open to everyone — INCLUDING the outgoing
|
|
84
|
-
// holder, which could then win the re-claim race indefinitely and starve the waiters that
|
|
85
|
-
// maxHold exists to admit. So the overrunning holder stands down for a full maxHold, which
|
|
86
|
-
// is a window every waiter is guaranteed to poll inside. A single-ttl cooldown was tried
|
|
87
|
-
// first and is NOT enough: the holder just waits it out and opens a fresh `since`.
|
|
88
|
-
// The bar lapses on its own, so a file nobody else wants returns to its owner.
|
|
89
|
-
if (c && c.by === self && overheld(c) && held(c) < 2 * cap(c)) return false
|
|
90
|
-
claim(region, intent)
|
|
91
|
-
const after = shared.get(region)
|
|
92
|
-
return !(after && after.by !== self) // verify: did my claim survive?
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// FLOW to open space: try my preferred regions in order, take the first I can acquire.
|
|
96
|
-
// This is what makes the swarm spread out with nobody assigning work.
|
|
97
|
-
const acquireAny = (candidates, intent) => {
|
|
98
|
-
for (const r of candidates) if (acquire(r, intent)) return r
|
|
99
|
-
return null
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Everything currently claimed (live), as a snapshot — for awareness / a Control Room.
|
|
103
|
-
const board = () => {
|
|
104
|
-
const out = []
|
|
105
|
-
for (const [region, c] of shared.entries()) if (!expired(c)) out.push({ region, ...c })
|
|
106
|
-
return out
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return { sense, free, claim, release, acquire, acquireAny, board }
|
|
110
|
-
}
|
|
1
|
+
// hive-coord.js — the Hive coordination layer (v0 prototype).
|
|
2
|
+
//
|
|
3
|
+
// The bet: AI coding agents work fast and BLIND — they collide because, unlike a human
|
|
4
|
+
// team, they have no way to coordinate BEFORE they edit. Merging (ICR) cleans up after a
|
|
5
|
+
// collision. This prevents the collision in the first place.
|
|
6
|
+
//
|
|
7
|
+
// The design is a real hive: NO central controller, no conductor, no extra agent. Order
|
|
8
|
+
// emerges from each agent following one local rule against a shared medium — exactly how
|
|
9
|
+
// bees coordinate via traces in the environment (stigmergy), and how millions of Ethernet
|
|
10
|
+
// devices share one wire with no master (carrier-sense / collision-detect / backoff).
|
|
11
|
+
//
|
|
12
|
+
// The shared medium is a CRDT map living IN the codebase doc (a Y.Map in Hivecode; a plain
|
|
13
|
+
// Map in tests). Each region of work (a file, or a unit) can hold one CLAIM:
|
|
14
|
+
// { by: agentId, intent: string, at: time, ttl: ms }
|
|
15
|
+
//
|
|
16
|
+
// An agent's entire protocol — no coordinator anywhere:
|
|
17
|
+
// 1. SENSE — read the shared map. Is my target region claimed and fresh?
|
|
18
|
+
// 2. FLOW — if taken, move to open space (pick another free region). This is the
|
|
19
|
+
// emergent load-balancing: agents spread across the codebase on their own.
|
|
20
|
+
// 3. CLAIM — write my claim to the free region.
|
|
21
|
+
// 4. VERIFY — re-read (collision-detect): if a concurrent claim beat mine, BACK OFF and
|
|
22
|
+
// retry later. (Like Ethernet: listen, send, detect collision, back off.)
|
|
23
|
+
// 5. RELEASE — when done, delete my claim and (optionally) leave a "done" trace.
|
|
24
|
+
//
|
|
25
|
+
// EVAPORATION: claims carry a TTL. A crashed or stalled agent's claim simply expires, and
|
|
26
|
+
// the region frees itself — no central garbage collector, no deadlock. (The pheromone fades.)
|
|
27
|
+
//
|
|
28
|
+
// `shared` is any object with get(key)/set(key,val)/delete(key)/entries() — so the same
|
|
29
|
+
// code runs on a JS Map (tests) and a Yjs Y.Map (production). `now` is injectable so tests
|
|
30
|
+
// can drive a deterministic virtual clock.
|
|
31
|
+
|
|
32
|
+
export function makeCoordinator(shared, self, opts = {}) {
|
|
33
|
+
const ttl = opts.ttl != null ? opts.ttl : 5000
|
|
34
|
+
// A holder may RENEW its own claim, because a long edit should not lose the file mid-write.
|
|
35
|
+
// But renewal cannot be unbounded, or the lease stops being a lease. MEASURED on a 12-agent
|
|
36
|
+
// run: one agent re-claimed site/pricing.md eight times and legal/terms.md seven times and
|
|
37
|
+
// released twice, and since `claim` reset `at` on every call the 60s TTL never once elapsed.
|
|
38
|
+
// Six other agents sensed a live owner for the whole run; sixteen messages asked it to let go.
|
|
39
|
+
// `since` records the FIRST acquisition and survives renewal, so what caps a claim is total
|
|
40
|
+
// hold time rather than time since the holder last touched it.
|
|
41
|
+
const maxHold = opts.maxHold != null ? opts.maxHold : ttl * 3
|
|
42
|
+
const now = opts.now || (() => Date.now())
|
|
43
|
+
const held = (c) => now() - (c.since != null ? c.since : c.at) // total, across renewals
|
|
44
|
+
const cap = (c) => (c.maxHold != null ? c.maxHold : maxHold)
|
|
45
|
+
const overheld = (c) => !!c && held(c) >= cap(c)
|
|
46
|
+
const expired = (c) => !c || (now() - c.at) >= c.ttl || overheld(c)
|
|
47
|
+
|
|
48
|
+
// SENSE: the live, non-expired claim on a region (or null if open).
|
|
49
|
+
const sense = (region) => { const c = shared.get(region); return expired(c) ? null : c }
|
|
50
|
+
|
|
51
|
+
// Is this region open to ME? (free, expired, or already mine)
|
|
52
|
+
const free = (region) => { const c = shared.get(region); return expired(c) || c.by === self }
|
|
53
|
+
|
|
54
|
+
// A renewal keeps the original `since`; a fresh acquisition starts a new one. Claims written
|
|
55
|
+
// before `since` existed fall back to `at`, so an older room stays readable.
|
|
56
|
+
const claim = (region, intent) => {
|
|
57
|
+
const prev = shared.get(region)
|
|
58
|
+
const renewing = !!prev && prev.by === self && !expired(prev)
|
|
59
|
+
shared.set(region, {
|
|
60
|
+
by: self, intent, at: now(), ttl, maxHold,
|
|
61
|
+
since: renewing ? (prev.since != null ? prev.since : prev.at) : now(),
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
const release = (region) => { const c = shared.get(region); if (c && c.by === self) shared.delete(region) }
|
|
65
|
+
|
|
66
|
+
// ACQUIRE one region: sense → claim → verify. Returns true iff I now hold it *as far as this
|
|
67
|
+
// replica can tell*, which is a weaker statement than it looks and the caller must know it.
|
|
68
|
+
//
|
|
69
|
+
// THE VERIFY IS LOCAL, AND THAT IS ITS LIMIT. `claim()` writes into this replica's copy of the
|
|
70
|
+
// map and the re-read below sees that write immediately -- before it has reached the relay or
|
|
71
|
+
// any peer. So the check catches only a claim that had ALREADY propagated when I wrote. Fire
|
|
72
|
+
// three agents at one region in the same tick and all three are told yes; the CRDT then
|
|
73
|
+
// converges to a single winner a moment later, by which time all three have started editing.
|
|
74
|
+
// Measured 5/5 trials, three agents, every one of them granted.
|
|
75
|
+
//
|
|
76
|
+
// That is not a bug in this function -- a synchronous call cannot wait for a network -- but a
|
|
77
|
+
// lock that says yes to three callers is not a lock, so anything that is about to WRITE must
|
|
78
|
+
// use `claimSettled()` in sync.js, which re-reads the holder after the claim has round-tripped
|
|
79
|
+
// through the relay. This stays as the optimistic fast path for sensing and for flow.
|
|
80
|
+
const acquire = (region, intent) => {
|
|
81
|
+
const c = shared.get(region)
|
|
82
|
+
if (c && !expired(c) && c.by !== self) return false // sensed an owner → defer
|
|
83
|
+
// Once total hold is exhausted the region is open to everyone — INCLUDING the outgoing
|
|
84
|
+
// holder, which could then win the re-claim race indefinitely and starve the waiters that
|
|
85
|
+
// maxHold exists to admit. So the overrunning holder stands down for a full maxHold, which
|
|
86
|
+
// is a window every waiter is guaranteed to poll inside. A single-ttl cooldown was tried
|
|
87
|
+
// first and is NOT enough: the holder just waits it out and opens a fresh `since`.
|
|
88
|
+
// The bar lapses on its own, so a file nobody else wants returns to its owner.
|
|
89
|
+
if (c && c.by === self && overheld(c) && held(c) < 2 * cap(c)) return false
|
|
90
|
+
claim(region, intent)
|
|
91
|
+
const after = shared.get(region)
|
|
92
|
+
return !(after && after.by !== self) // verify: did my claim survive?
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// FLOW to open space: try my preferred regions in order, take the first I can acquire.
|
|
96
|
+
// This is what makes the swarm spread out with nobody assigning work.
|
|
97
|
+
const acquireAny = (candidates, intent) => {
|
|
98
|
+
for (const r of candidates) if (acquire(r, intent)) return r
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Everything currently claimed (live), as a snapshot — for awareness / a Control Room.
|
|
103
|
+
const board = () => {
|
|
104
|
+
const out = []
|
|
105
|
+
for (const [region, c] of shared.entries()) if (!expired(c)) out.push({ region, ...c })
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { sense, free, claim, release, acquire, acquireAny, board }
|
|
110
|
+
}
|