hivecode-mcp 0.14.2 → 0.14.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/TROUBLESHOOTING.md +1 -0
- package/hive-branch.js +68 -2
- package/hive-land.js +15 -1
- package/hive-mcp.js +59 -6
- package/hive-mux.js +4 -1
- package/hive-pr.js +42 -5
- package/hive-room-mode.js +130 -37
- package/hive-room-watcher.js +65 -4
- package/hive-slack.js +35 -2
- package/hive-transport.js +46 -7
- package/icr.js +7 -2
- package/lang-js.js +54 -10
- package/package.json +11 -3
- package/sync.js +265 -49
package/hive-room-watcher.js
CHANGED
|
@@ -68,7 +68,7 @@ export function saveRoomState(file, s) {
|
|
|
68
68
|
export function makeRoomWatcher(o) {
|
|
69
69
|
const {
|
|
70
70
|
gh, link, branch, label = LABEL, stateFile, deps,
|
|
71
|
-
ackMinutes = 10, maxMinutes = 120, maxRuns = 6, maxRunsPerDay = 20, soloGraceMinutes = 2,
|
|
71
|
+
ackMinutes = 10, maxMinutes = 120, maxRuns = 6, maxRunsPerDay = 20, soloGraceMinutes = 2, refusalRetryMinutes = 10,
|
|
72
72
|
fallbackAgent = '', host = os.hostname(),
|
|
73
73
|
now = () => Date.now(), log = () => {},
|
|
74
74
|
} = o
|
|
@@ -100,10 +100,23 @@ export function makeRoomWatcher(o) {
|
|
|
100
100
|
.split(roomId).join('[room id removed]')
|
|
101
101
|
.replace(/wss?:\/\/[^\s|)'"`]+(\|[^\s)'"`]*)?/g, '[relay link removed]')
|
|
102
102
|
.replace(/\broom-[A-Za-z0-9_-]{12,}/g, '[room id removed]')
|
|
103
|
+
// LOCAL PATHS: a refusal's "why" names files on the watcher's machine (a home folder, a temp
|
|
104
|
+
// checkout); the issue is public (0.14.3 P0-2, Claude's review). Not inside URLs.
|
|
105
|
+
.replace(/(?<![\w.:/\\-])(?:[A-Za-z]:[\\/]|\\\\|\/(?:home|Users|tmp|var|root|private|opt|mnt|srv|workspace|data)\/)[^\s'"`)\]]*/g, '[local path removed]')
|
|
103
106
|
const comment = (n, body) => { try { gh.comment(n, scrubRoom(body)) } catch (e) { log(`#${n}: could not comment on GitHub: ${e.message}`) } }
|
|
104
107
|
const today = () => new Date(now()).toISOString().slice(0, 10)
|
|
105
108
|
const runsToday = () => { if (state.day.date !== today()) state.day = { date: today(), runs: 0 }; return state.day.runs }
|
|
106
109
|
|
|
110
|
+
// A room line that may not arrive: deps.say is roomSay, confirmed by a second client. When it is
|
|
111
|
+
// not confirmed (or throws) the watcher logs it and carries on; the caller has already put the
|
|
112
|
+
// same news on GitHub, so nobody is left with neither (0.14.3 P0-2).
|
|
113
|
+
async function sayChecked(n, text) {
|
|
114
|
+
let r = null
|
|
115
|
+
try { r = await deps.say(text) } catch (e) { r = { ok: false, why: e && e.message } }
|
|
116
|
+
if (!r || !r.ok) log(`#${n}: could not confirm the room heard it (${(r && (r.why || r.code)) || 'no answer'}); the GitHub comment stands`)
|
|
117
|
+
return r
|
|
118
|
+
}
|
|
119
|
+
|
|
107
120
|
function needsHuman(n, why, lines = []) {
|
|
108
121
|
const it = state.issues[n]
|
|
109
122
|
if (it) it.status = 'needs-human'
|
|
@@ -197,9 +210,15 @@ export function makeRoomWatcher(o) {
|
|
|
197
210
|
const solo = (a.agentsSeen || []).length <= 1
|
|
198
211
|
const graceMs = (solo ? soloGraceMinutes : ackMinutes) * 60000
|
|
199
212
|
const allowUnreviewed = !!(a.unreviewedSince && now() - a.unreviewedSince >= graceMs && peek && peek.ok && peek.reachable.length <= 1)
|
|
200
|
-
|
|
213
|
+
// A REFUSAL IS TRIED AGAIN AFTER A PAUSE even when nothing changed (a push answered 503, a lock
|
|
214
|
+
// on the remote): once per refusalRetryMinutes, never every tick, and a repeat of the same
|
|
215
|
+
// refusal is neither said again nor counted again (antigravity-VM's attack 1). Failing tests
|
|
216
|
+
// are not retried this way: they wait for the room to change.
|
|
217
|
+
let lastFp = a.lastFailedFingerprint || ''
|
|
218
|
+
if (lastFp && a.lastRefusal && now() - (a.refusalTriedAt || a.lastRefusal.at) >= refusalRetryMinutes * 60000) { lastFp = ''; a.refusalTriedAt = now() }
|
|
219
|
+
const r = await deps.landIssue({ taskId: a.taskId, issue: { number: n, title: it.title }, lastFailedFingerprint: lastFp, allowUnreviewed })
|
|
201
220
|
if (r && !r.ok && r.blocked === 'unreviewed') {
|
|
202
|
-
if (!a.unreviewedSince) {
|
|
221
|
+
if (!a.unreviewedSince && !a.unreviewedSaid) {
|
|
203
222
|
a.unreviewedSince = now()
|
|
204
223
|
const files = (r.files || []).slice(0, 6).join(', ') || 'the changed files'
|
|
205
224
|
if (solo) {
|
|
@@ -208,8 +227,16 @@ export function makeRoomWatcher(o) {
|
|
|
208
227
|
} else {
|
|
209
228
|
await deps.say(`@all issue #${n} is done and waiting for a review of ${files} by someone who did not write them (hive_review). Nothing lands until then.`)
|
|
210
229
|
}
|
|
230
|
+
a.unreviewedSaid = true
|
|
211
231
|
}
|
|
212
|
-
|
|
232
|
+
if (!a.unreviewedSince) a.unreviewedSince = now()
|
|
233
|
+
} else if (r && !r.ok && !r.skipped && !r.code && !r.fingerprint) {
|
|
234
|
+
// Only "not ready" (work reopened, a claim taken) restarts the review wait. A refusal or a
|
|
235
|
+
// skipped landing is not a change in the work: resetting here made a refused solo landing
|
|
236
|
+
// re-announce "nobody else to review" every grace period (antigravity-VM's attack 3).
|
|
237
|
+
a.unreviewedSince = 0
|
|
238
|
+
a.unreviewedSaid = false
|
|
239
|
+
}
|
|
213
240
|
if (r && r.state && 'lastFailedFingerprint' in r.state) a.lastFailedFingerprint = r.state.lastFailedFingerprint || ''
|
|
214
241
|
if (r && r.ok && a.kind === 'review') {
|
|
215
242
|
const pr = r.pr || {}
|
|
@@ -234,8 +261,38 @@ export function makeRoomWatcher(o) {
|
|
|
234
261
|
}
|
|
235
262
|
if (r && r.code === 'Q576' && !r.skipped) {
|
|
236
263
|
a.runs = (a.runs || 0) + 1
|
|
264
|
+
a.lastRefusal = null // failing tests wait for the room to change; they are not retried on a timer
|
|
237
265
|
if (a.runs >= maxRuns) return needsHuman(n, `the tests failed ${a.runs} times in a row (the limit is ${maxRuns}).`, (peek && peek.lastLines) || [])
|
|
238
266
|
comment(n, `The tests failed on the agents' work, so nothing was pushed. It went back to the room (attempt ${a.runs} of ${maxRuns}).\n\n${MARK('room', { kind: 'tests-failed', run: a.runs })}`)
|
|
267
|
+
// SAID BY THE WATCHER, CONFIRMED: landIssue's own room line went through a connection that
|
|
268
|
+
// whenDone had already stopped, so the room never heard its tests failed (0.14.3 P0-2).
|
|
269
|
+
await sayChecked(n, `@all the tests failed when landing issue #${n} (attempt ${a.runs} of ${maxRuns}): ${oneLine(r.why, 300)} Nothing was pushed. Fix them in the room; the landing is tried again when the room's files or the branch change.`)
|
|
270
|
+
}
|
|
271
|
+
// A LANDING REFUSED FOR ANY OTHER REASON IS SAID, never swallowed (0.14.3 P0-2). On 0.14.2 a
|
|
272
|
+
// refusal (Q506, a push refused, no git identity...) matched no branch here: nothing in the
|
|
273
|
+
// room, nothing on the issue, and the next tick's identical fingerprint was a silent skip, so
|
|
274
|
+
// issue #9 sat "done" and landed nothing with no word anywhere. Said once per refusal (same
|
|
275
|
+
// code, same fingerprint), counted like failing tests, then a person. The issue comment goes
|
|
276
|
+
// FIRST: an unreachable room must never mean nobody heard (Claude's review).
|
|
277
|
+
if (r && !r.ok && !r.skipped && r.blocked !== 'unreviewed' && r.code !== 'Q576' && (r.code || r.fingerprint)) {
|
|
278
|
+
const fp = r.fingerprint || (r.state && r.state.lastFailedFingerprint) || ''
|
|
279
|
+
const key = `${r.code || ''}|${fp || oneLine(r.why, 120)}`
|
|
280
|
+
if (a.refusedKey !== key) {
|
|
281
|
+
a.refusedKey = key
|
|
282
|
+
a.refusals = (a.refusals || 0) + 1
|
|
283
|
+
const code = r.code || 'refused'
|
|
284
|
+
const why = oneLine(r.why, 300) || 'no reason given'
|
|
285
|
+
a.lastRefusal = { code, why, at: now() }
|
|
286
|
+
log(`#${n}: landing refused (${code}): ${why}`)
|
|
287
|
+
if (a.refusals >= maxRuns) return needsHuman(n, `the landing was refused ${a.refusals} times (the limit is ${maxRuns}); the last time ${code}: ${why}`, (peek && peek.lastLines) || [])
|
|
288
|
+
comment(a.kind === 'review' ? a.pr : n, `The agents' work is done, but Hivecode refused to land it (${code}): ${oneLine(r.why, 400) || 'no reason given'} Nothing was pushed. It went back to the room (refusal ${a.refusals} of ${maxRuns}); it is tried again when the room's files or the branch change.\n\n${MARK('room', { kind: 'refused', code })}`)
|
|
289
|
+
await sayChecked(n, `@all issue #${n} is done, but its landing was REFUSED (${code}): ${why} Nothing was pushed. Fix the cause in the room; the landing is tried again when the room's files or the branch change.`)
|
|
290
|
+
} else if (a.lastRefusal) {
|
|
291
|
+
// The same refusal again (a retry after the pause): not said or counted again, but
|
|
292
|
+
// `github status` shows it is still happening, so a silent wait is never mistaken for
|
|
293
|
+
// progress (Claude's review).
|
|
294
|
+
a.lastRefusal.seen = now()
|
|
295
|
+
}
|
|
239
296
|
}
|
|
240
297
|
if (now() - a.postedAt > maxMinutes * 60000) return needsHuman(n, `the room worked on it for more than ${maxMinutes} minutes without a landing.`, (peek && peek.lastLines) || [])
|
|
241
298
|
|
|
@@ -395,6 +452,10 @@ export function describeRoomState(s, { now = Date.now(), maxRunsPerDay = (s && s
|
|
|
395
452
|
out.push(` working on ${what} for ${mins(a.postedAt)} min; ${a.engagedAt ? 'an agent has taken it' : 'NO agent has taken it yet'}${a.runs ? `; tests failed ${a.runs} time(s)` : ''}`)
|
|
396
453
|
if (a.fallback) out.push(` fallback agent started ${mins(a.fallback.at)} min ago (${a.fallback.why})`)
|
|
397
454
|
if (a.unreviewedSince) out.push(` done, waiting ${mins(a.unreviewedSince)} min for a review by an agent that did not write it`)
|
|
455
|
+
if (a.lastRefusal) {
|
|
456
|
+
const still = a.lastRefusal.seen ? `; still refused (same reason) for ${mins(a.lastRefusal.at)} min, last tried ${mins(a.lastRefusal.seen)} min ago` : ''
|
|
457
|
+
out.push(` landing refused ${mins(a.lastRefusal.at)} min ago (${a.lastRefusal.code}): ${oneLine(a.lastRefusal.why, 200)}${still}`)
|
|
458
|
+
}
|
|
398
459
|
} else if (s.openPr) {
|
|
399
460
|
out.push(` waiting for PR #${s.openPr} to be merged or closed before the next issue (one open PR per room)`)
|
|
400
461
|
} else {
|
package/hive-slack.js
CHANGED
|
@@ -180,6 +180,28 @@ export function makeQueue({
|
|
|
180
180
|
|
|
181
181
|
export const SLACK_TIMEOUT_MS = 15000
|
|
182
182
|
|
|
183
|
+
// HOW SLACK READS A CALL'S ARGUMENTS (0.14.3 P0-4). Read methods take their arguments only from the
|
|
184
|
+
// query string or a form body: a JSON body is ignored, so on 0.14.2 conversations.info answered
|
|
185
|
+
// invalid_arguments and users.info user_not_found in a real workspace (measured 9 Nov 2026), which
|
|
186
|
+
// broke `slack check`, the --slack-channel preflight, inbound stop/retry and stop verification.
|
|
187
|
+
// Every fake Slack in the tests accepted JSON, so all of it was green. Write methods (chat.*,
|
|
188
|
+
// agents.*, reactions.*...) take JSON and stay JSON: their blocks and metadata are nested objects.
|
|
189
|
+
const FORM_METHOD = /^(?:(?:conversations|users|team|bots)\.[A-Za-z.]+|auth\.test)$/
|
|
190
|
+
export function slackEncoding(method) {
|
|
191
|
+
return FORM_METHOD.test(String(method || '')) ? 'form' : 'json'
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A form body Slack reads: undefined and null are LEFT OUT (never the text "undefined", which Slack
|
|
195
|
+
* would read as a real channel or user id); a nested value is JSON inside its field, as Slack takes it. */
|
|
196
|
+
export function slackForm(body = {}) {
|
|
197
|
+
const p = new URLSearchParams()
|
|
198
|
+
for (const [k, v] of Object.entries(body || {})) {
|
|
199
|
+
if (v === undefined || v === null) continue
|
|
200
|
+
p.append(k, typeof v === 'object' ? JSON.stringify(v) : String(v))
|
|
201
|
+
}
|
|
202
|
+
return p.toString()
|
|
203
|
+
}
|
|
204
|
+
|
|
183
205
|
/** POST to Slack's Web API. Kept tiny and injectable so the bridge can be tested without a
|
|
184
206
|
* workspace -- the thing worth testing is our routing, not Slack's HTTP.
|
|
185
207
|
* Applies a 15s AbortSignal timeout by default so a hung TCP socket never stalls the queue. */
|
|
@@ -191,10 +213,14 @@ export async function slackPost(
|
|
|
191
213
|
apiBase = process.env.HIVE_SLACK_API || DEFAULT_API,
|
|
192
214
|
timeoutMs = Number(process.env.HIVE_SLACK_TIMEOUT_MS) || SLACK_TIMEOUT_MS,
|
|
193
215
|
) {
|
|
216
|
+
const form = slackEncoding(method) === 'form'
|
|
194
217
|
const r = await fetchImpl(`${apiBase}/${method}`, {
|
|
195
218
|
method: 'POST',
|
|
196
|
-
headers: {
|
|
197
|
-
|
|
219
|
+
headers: {
|
|
220
|
+
'Content-Type': form ? 'application/x-www-form-urlencoded; charset=utf-8' : 'application/json; charset=utf-8',
|
|
221
|
+
Authorization: `Bearer ${token}`,
|
|
222
|
+
},
|
|
223
|
+
body: form ? slackForm(body) : JSON.stringify(body),
|
|
198
224
|
signal: AbortSignal.timeout(timeoutMs),
|
|
199
225
|
})
|
|
200
226
|
const retryHeader = r.headers && typeof r.headers.get === 'function' ? Number(r.headers.get('retry-after') || 0) : 0
|
|
@@ -2120,6 +2146,13 @@ export async function runSlackCheck({
|
|
|
2120
2146
|
clearTimeout(waitTimer)
|
|
2121
2147
|
|
|
2122
2148
|
if (!arrived) {
|
|
2149
|
+
// Over HTTPS polling nothing is subscribed: the bridge reads the thread itself, and its read
|
|
2150
|
+
// scopes were checked above. So no reply means nobody replied in time, and sending the person
|
|
2151
|
+
// off to reinstall the app was wrong (9 Nov, the first real check: 0 replies in the thread).
|
|
2152
|
+
if (pollInbound) {
|
|
2153
|
+
logErr(`Q634 no reply arrived in the thread within ${Math.round(timeoutMs / 1000)} s. Replies are read over HTTPS, which needs no event subscription, and the read scopes are in place: reply anything in the thread of "Hivecode check: reply anything in this thread" in #${channelName} while the check waits (raise --timeout for more time).`)
|
|
2154
|
+
return 1
|
|
2155
|
+
}
|
|
2123
2156
|
const manifestName = agent ? 'slack-app-manifest-agent.json' : 'slack-app-manifest-inbound.json'
|
|
2124
2157
|
logErr(`Q634 replies did not arrive. Event subscriptions are probably missing: reinstall from ${manifestName}`)
|
|
2125
2158
|
return 1
|
package/hive-transport.js
CHANGED
|
@@ -373,6 +373,7 @@ export class PollSocket extends SocketShape {
|
|
|
373
373
|
_lost(reason, code = 1006) {
|
|
374
374
|
if (this.readyState === CLOSED) return
|
|
375
375
|
this.readyState = CLOSED
|
|
376
|
+
clearTimeout(this._closeTimer)
|
|
376
377
|
if (this._sid) this._req('close', undefined, {}, 5000).catch(() => { })
|
|
377
378
|
fire(this, 'close', { type: 'close', code, reason, wasClean: false, target: this })
|
|
378
379
|
}
|
|
@@ -392,7 +393,7 @@ export class PollSocket extends SocketShape {
|
|
|
392
393
|
// A proxy that cuts long requests shows up as a failure after a long wait: hold shorter.
|
|
393
394
|
if (startedAt && now - startedAt > 5000 && this._hold > 4000) this._hold = Math.max(4000, Math.floor(this._hold / 2))
|
|
394
395
|
await sleep(Math.min(2000, 250 * 2 ** Math.min(3, Math.floor((now - this._troubleSince) / 5000))))
|
|
395
|
-
return this.readyState === OPEN
|
|
396
|
+
return this.readyState === OPEN || this.readyState === CLOSING
|
|
396
397
|
}
|
|
397
398
|
_ok() { this._troubleSince = 0 }
|
|
398
399
|
async _recvLoop() {
|
|
@@ -429,17 +430,18 @@ export class PollSocket extends SocketShape {
|
|
|
429
430
|
if (!this._flushQueued) { this._flushQueued = true; setImmediate(() => { this._flushQueued = false; this._flush() }) }
|
|
430
431
|
}
|
|
431
432
|
async _flush() {
|
|
432
|
-
|
|
433
|
+
const live = () => this.readyState === OPEN || this.readyState === CLOSING
|
|
434
|
+
if (this._sending || !this._outbox.length || !live()) { this._closeIfDrained(); return }
|
|
433
435
|
this._sending = true
|
|
434
436
|
try {
|
|
435
|
-
while (this._outbox.length &&
|
|
437
|
+
while (this._outbox.length && live()) {
|
|
436
438
|
let n = 0, bytes = 0
|
|
437
439
|
while (n < this._outbox.length && n < 512 && (n === 0 || bytes + this._outbox[n].length <= 4 * 1048576)) { bytes += this._outbox[n].length; n++ }
|
|
438
440
|
const batch = this._outbox.slice(0, n)
|
|
439
441
|
const first = this._outSeq
|
|
440
442
|
const body = packFrames(batch)
|
|
441
443
|
for (;;) {
|
|
442
|
-
if (
|
|
444
|
+
if (!live()) return
|
|
443
445
|
const started = Date.now()
|
|
444
446
|
let r
|
|
445
447
|
try { r = await this._req('send', body, { 'content-type': 'application/octet-stream', 'x-hive-seq': String(first) }, 60000) }
|
|
@@ -455,17 +457,54 @@ export class PollSocket extends SocketShape {
|
|
|
455
457
|
this._outbox.splice(0, n)
|
|
456
458
|
this._outSeq = first + n
|
|
457
459
|
}
|
|
458
|
-
} finally { this._sending = false }
|
|
460
|
+
} finally { this._sending = false; this._closeIfDrained() }
|
|
459
461
|
}
|
|
462
|
+
// CLOSE DELIVERS WHAT WAS SENT FIRST, as a WebSocket's does (0.14.3 P0-1). A WebSocket hands its
|
|
463
|
+
// bytes to the OS on send(), so ws.close() still delivers them; this socket keeps them in _outbox
|
|
464
|
+
// until the relay accepts them, and close() used to drop that array: an edit made just before the
|
|
465
|
+
// agent left never reached the room while hive_edit had already said it was syncing (8-9 Nov mock
|
|
466
|
+
// run, both Antigravity VM agents). Now close() keeps sending for up to CLOSE_DRAIN_MS, then ends
|
|
467
|
+
// the session. The pending request keeps the process alive meanwhile, so an exit waits for it.
|
|
460
468
|
close(code = 1000, reason = '') {
|
|
461
469
|
if (this.readyState === CLOSING || this.readyState === CLOSED) return
|
|
470
|
+
if (this.readyState === OPEN && (this._outbox.length || this._sending)) {
|
|
471
|
+
this.readyState = CLOSING
|
|
472
|
+
this._closeWith = { code, reason }
|
|
473
|
+
this._closeTimer = setTimeout(() => this._endClose(false), PollSocket.CLOSE_DRAIN_MS)
|
|
474
|
+
this._flush()
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
this._closeWith = { code, reason }
|
|
478
|
+
this._endClose(true)
|
|
479
|
+
}
|
|
480
|
+
_closeIfDrained() {
|
|
481
|
+
if (this.readyState === CLOSING && !this._sending && !this._outbox.length) this._endClose(true)
|
|
482
|
+
}
|
|
483
|
+
_endClose(drained) {
|
|
484
|
+
if (this.readyState === CLOSED) return
|
|
485
|
+
clearTimeout(this._closeTimer)
|
|
462
486
|
const had = !!this._sid
|
|
487
|
+
const { code, reason } = this._closeWith || { code: 1000, reason: '' }
|
|
463
488
|
this.readyState = CLOSED
|
|
464
489
|
if (had) this._req('close', undefined, {}, 5000).catch(() => { })
|
|
465
|
-
setImmediate(() => fire(this, 'close', { type: 'close', code, reason, wasClean:
|
|
490
|
+
setImmediate(() => fire(this, 'close', { type: 'close', code, reason, wasClean: drained, target: this }))
|
|
491
|
+
}
|
|
492
|
+
/** Resolves once nothing is waiting to reach the relay (or the socket is gone): the drain, observable. */
|
|
493
|
+
drained(timeoutMs = PollSocket.CLOSE_DRAIN_MS) {
|
|
494
|
+
const end = Date.now() + timeoutMs
|
|
495
|
+
return new Promise((resolve) => {
|
|
496
|
+
const tick = () => {
|
|
497
|
+
if (this.readyState === CLOSED || (!this._outbox.length && !this._sending)) return resolve(!this._outbox.length)
|
|
498
|
+
if (Date.now() >= end) return resolve(false)
|
|
499
|
+
setTimeout(tick, 25)
|
|
500
|
+
}
|
|
501
|
+
tick()
|
|
502
|
+
})
|
|
466
503
|
}
|
|
467
|
-
terminate()
|
|
504
|
+
// terminate is for a dead link (the liveness check): nothing can be delivered on it, so no drain.
|
|
505
|
+
terminate() { this._closeWith = { code: 1006, reason: 'terminated' }; this._endClose(false) }
|
|
468
506
|
}
|
|
507
|
+
PollSocket.CLOSE_DRAIN_MS = 10000
|
|
469
508
|
|
|
470
509
|
/**
|
|
471
510
|
* The socket every provider gets: a WebSocket, or long-poll where WebSockets are refused (above).
|
package/icr.js
CHANGED
|
@@ -802,12 +802,17 @@ export function structuralMerge(base, a, b, opts = {}) {
|
|
|
802
802
|
// ASYNC/AWAIT SYNCHRONIZATION (intent):
|
|
803
803
|
// When a declaration was synchronous in base and became ASYNC in the merged result,
|
|
804
804
|
// upgrade call sites inside async callers with `await` so the contract holds.
|
|
805
|
+
// ONLY a function that EXISTED in the base as sync (0.14.3 P0-5). A function new in this merge was
|
|
806
|
+
// written async by its author, together with its callers: nothing was "made async" under anyone.
|
|
807
|
+
// Counting it rewrote authored lines (`return f()` -> `return await f()`, credited to the author,
|
|
808
|
+
// silently), and two peers making the same insert at once stacked in the shared text: the room's
|
|
809
|
+
// own server.js read `return await await await admitMux(req, q)` (9 Nov).
|
|
805
810
|
const asyncUpgrades = []
|
|
806
811
|
if (lang.isAsyncDecl && lang.upgradeCallSitesToAwait) {
|
|
807
812
|
for (const name of mergedNames) {
|
|
808
|
-
const
|
|
813
|
+
const wasSyncInBase = baseNames.has(name) && !lang.isAsyncDecl(base, name)
|
|
809
814
|
const isAsyncInMerged = lang.isAsyncDecl(merged, name)
|
|
810
|
-
if (
|
|
815
|
+
if (wasSyncInBase && isAsyncInMerged) {
|
|
811
816
|
const upgraded = lang.upgradeCallSitesToAwait(merged, name)
|
|
812
817
|
if (upgraded !== merged && lang.parses(upgraded)) {
|
|
813
818
|
merged = upgraded
|
package/lang-js.js
CHANGED
|
@@ -230,45 +230,63 @@ function isAsyncDecl(src, name) {
|
|
|
230
230
|
|
|
231
231
|
// Upgrade free call sites of `fnName` to `await fnName(...)` if they are inside an async parent
|
|
232
232
|
// and not already awaited.
|
|
233
|
+
//
|
|
234
|
+
// Left alone (0.14.3 P0-5): a call that is already awaited (`await f()`, `await (f())`); a call that
|
|
235
|
+
// IS the value an async function returns (`return f()`, `async () => f()`) -- OUTSIDE a try, where
|
|
236
|
+
// returning the promise resolves to the same value. Inside a try (its block or its catch) `return f()`
|
|
237
|
+
// is NOT the same once f is async: a rejection escapes the catch and a finally runs before f settles,
|
|
238
|
+
// so there it is upgraded, which is what the feature is for (Claude-cloud's review). A call that is an
|
|
239
|
+
// argument of an awaited call (`await g(f())`) is NOT awaited by that await and is upgraded too.
|
|
240
|
+
// Each position is inserted at most once.
|
|
233
241
|
function upgradeCallSitesToAwait(src, fnName) {
|
|
234
242
|
try {
|
|
235
243
|
const ast = parse(src)
|
|
236
|
-
const spots =
|
|
244
|
+
const spots = new Set()
|
|
237
245
|
|
|
238
|
-
|
|
246
|
+
// awaited: this node IS the operand of an await (through parentheses or an optional chain only).
|
|
247
|
+
// inTry: this node sits in a try block or catch clause of the current function.
|
|
248
|
+
function walkAST(node, parent, isAsyncScope = false, awaited = false, inTry = false) {
|
|
239
249
|
if (!node || typeof node.type !== 'string') return
|
|
240
250
|
|
|
241
251
|
let childAsyncScope = isAsyncScope
|
|
252
|
+
let childInTry = inTry
|
|
242
253
|
if (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') {
|
|
243
254
|
childAsyncScope = !!node.async
|
|
255
|
+
childInTry = false // a nested function has its own try scopes
|
|
244
256
|
}
|
|
257
|
+
const passAwait = node.type === 'AwaitExpression' ? true
|
|
258
|
+
: (node.type === 'ParenthesizedExpression' || node.type === 'ChainExpression') ? awaited : false
|
|
245
259
|
|
|
246
260
|
if (node.type === 'CallExpression') {
|
|
247
261
|
let isTarget = false
|
|
248
262
|
if (node.callee.type === 'Identifier' && node.callee.name === fnName) isTarget = true
|
|
249
263
|
if (isTarget && childAsyncScope) {
|
|
250
|
-
const alreadyAwaited = parent && parent.type === 'AwaitExpression'
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
264
|
+
const alreadyAwaited = awaited || (parent && parent.type === 'AwaitExpression')
|
|
265
|
+
const returnedAsIs = !inTry && parent && (parent.type === 'ReturnStatement' ||
|
|
266
|
+
(parent.type === 'ArrowFunctionExpression' && parent.body === node))
|
|
267
|
+
if (!alreadyAwaited && !returnedAsIs) spots.add(node.start)
|
|
254
268
|
}
|
|
255
269
|
}
|
|
256
270
|
|
|
257
271
|
for (const k of Object.keys(node)) {
|
|
258
272
|
if (k === 'type' || k === 'start' || k === 'end' || k === 'loc' || k === 'range') continue
|
|
259
273
|
const v = node[k]
|
|
274
|
+
// A try's block and its catch clause are "in try"; its finally is not (a return there is final).
|
|
275
|
+
const kInTry = node.type === 'TryStatement' ? (k === 'block' || k === 'handler') || childInTry : childInTry
|
|
260
276
|
if (Array.isArray(v)) {
|
|
261
|
-
for (const c of v) walkAST(c, node, childAsyncScope)
|
|
277
|
+
for (const c of v) walkAST(c, node, childAsyncScope, passAwait, kInTry)
|
|
262
278
|
} else if (v && typeof v.type === 'string') {
|
|
263
|
-
walkAST(v, node, childAsyncScope)
|
|
279
|
+
walkAST(v, node, childAsyncScope, passAwait, kInTry)
|
|
264
280
|
}
|
|
265
281
|
}
|
|
266
282
|
}
|
|
267
283
|
|
|
268
284
|
walkAST(ast, null, false)
|
|
269
|
-
if (!spots.
|
|
285
|
+
if (!spots.size) return src
|
|
270
286
|
|
|
271
|
-
spots.sort((a, b) => b - a)
|
|
287
|
+
const order = [...spots].sort((a, b) => b - a)
|
|
288
|
+
spots.clear()
|
|
289
|
+
for (const p of order) spots.add(p)
|
|
272
290
|
let out = src
|
|
273
291
|
for (const pos of spots) {
|
|
274
292
|
out = out.slice(0, pos) + 'await ' + out.slice(pos)
|
|
@@ -279,6 +297,31 @@ function upgradeCallSitesToAwait(src, fnName) {
|
|
|
279
297
|
}
|
|
280
298
|
}
|
|
281
299
|
|
|
300
|
+
// STACKED AWAITS LEFT BY A CRDT UNION (0.14.3 P0-5-3). Two peers making the same sync -> async
|
|
301
|
+
// change at once each insert `await ` before the same call; the shared text keeps BOTH inserts, so
|
|
302
|
+
// the room reads `await await f()`. No merge can prevent that (each peer's merge is right on its own),
|
|
303
|
+
// so the engine removes it afterwards. Returns the [start, end) spans to delete: for every
|
|
304
|
+
// `await await x`, the OUTER `await` and the whitespace after it, and only when nothing but whitespace
|
|
305
|
+
// separates the two (so `await (await x)`, a comment, or a string is never touched). On the same
|
|
306
|
+
// converged text every peer computes the same spans, so their deletes are of the same characters.
|
|
307
|
+
function stackedAwaits(src) {
|
|
308
|
+
let ast
|
|
309
|
+
try { ast = parse(src) } catch { return [] }
|
|
310
|
+
const spans = []
|
|
311
|
+
const walk = (node) => {
|
|
312
|
+
if (!node || typeof node.type !== 'string') return
|
|
313
|
+
if (node.type === 'AwaitExpression' && node.argument && node.argument.type === 'AwaitExpression' &&
|
|
314
|
+
/^await\s+$/.test(src.slice(node.start, node.argument.start))) spans.push([node.start, node.argument.start])
|
|
315
|
+
for (const k of Object.keys(node)) {
|
|
316
|
+
if (k === 'type' || k === 'start' || k === 'end' || k === 'loc' || k === 'range') continue
|
|
317
|
+
const v = node[k]
|
|
318
|
+
if (Array.isArray(v)) { for (const c of v) walk(c) } else if (v && typeof v.type === 'string') walk(v)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
walk(ast)
|
|
322
|
+
return spans.sort((a, b) => a[0] - b[0])
|
|
323
|
+
}
|
|
324
|
+
|
|
282
325
|
// Find callers of `fnName` that are synchronous (cannot be auto-awaited without changing the caller's signature)
|
|
283
326
|
function syncCallSitesOf(src, fnName) {
|
|
284
327
|
const syncCallers = []
|
|
@@ -641,6 +684,7 @@ export const javascript = {
|
|
|
641
684
|
declBody,
|
|
642
685
|
isAsyncDecl,
|
|
643
686
|
upgradeCallSitesToAwait,
|
|
687
|
+
stackedAwaits, // spans of `await await` a CRDT union left (P0-5-3)
|
|
644
688
|
syncCallSitesOf,
|
|
645
689
|
renameRefs, // rewrites every matching identifier (kept for reference)
|
|
646
690
|
renameFreeRefs, // scope-aware — only rewrites references that resolve to the binding
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hivecode-mcp",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.3",
|
|
4
4
|
"description": "MCP server that lets any AI coding agent join a Hivecode room — humans and multiple AI agents edit one live codebase with folder-scoped permissions and approval gates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,7 +36,11 @@
|
|
|
36
36
|
"hive-version.js",
|
|
37
37
|
"hive-pr-staleness.js",
|
|
38
38
|
"hive-pr.js",
|
|
39
|
-
"hive-branch.js",
|
|
39
|
+
"hive-branch.js",
|
|
40
|
+
"hive-land.js",
|
|
41
|
+
"hive-repos.js",
|
|
42
|
+
"hive-room-mode.js",
|
|
43
|
+
"hive-room-watcher.js",
|
|
40
44
|
"hive-mailbox.js",
|
|
41
45
|
"hive-obligations.js",
|
|
42
46
|
"hive-receipts.js",
|
|
@@ -188,5 +192,9 @@
|
|
|
188
192
|
"doc": "docs",
|
|
189
193
|
"test": "tests"
|
|
190
194
|
},
|
|
191
|
-
"author": "Ganesh Shivlal Kute <support@getgrm.tech>"
|
|
195
|
+
"author": "Ganesh Shivlal Kute <support@getgrm.tech>",
|
|
196
|
+
"repository": {
|
|
197
|
+
"type": "git",
|
|
198
|
+
"url": "git+https://github.com/Ganesh-Kute/HiveCode.git"
|
|
199
|
+
}
|
|
192
200
|
}
|