hivecode-mcp 0.14.2 → 0.14.4
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 +41 -98
- package/docs/TROUBLESHOOTING.md +4 -0
- package/hive-await.js +57 -21
- package/hive-branch.js +68 -2
- package/hive-gate.js +116 -2
- package/hive-instructions.js +110 -61
- package/hive-land.js +15 -1
- package/hive-mailbox.js +74 -7
- package/hive-mcp.js +258 -47
- package/hive-mux.js +4 -1
- package/hive-notify.js +21 -5
- package/hive-pr.js +42 -5
- package/hive-receipts.js +56 -5
- package/hive-room-mode.js +143 -37
- package/hive-room-watcher.js +95 -16
- package/hive-seen.js +135 -0
- package/hive-slack.js +62 -4
- package/hive-stop-hook.js +33 -18
- package/hive-transport.js +85 -11
- package/icr.js +7 -2
- package/lang-js.js +54 -10
- package/package.json +13 -4
- package/sync.js +419 -225
- package/docs/HIVE_RULES.md +0 -41
package/hive-room-watcher.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import fs from 'fs'
|
|
23
23
|
import os from 'os'
|
|
24
24
|
import path from 'path'
|
|
25
|
+
import * as SEEN from './hive-seen.js' // replies since the post, by Yjs id (0.14.4)
|
|
25
26
|
import { startSync, parseLink } from './sync.js'
|
|
26
27
|
import * as ACT from './hive-activity.js'
|
|
27
28
|
import { LABEL, HUMAN_LABEL, PUSHERS } from './hive-github.js'
|
|
@@ -68,7 +69,7 @@ export function saveRoomState(file, s) {
|
|
|
68
69
|
export function makeRoomWatcher(o) {
|
|
69
70
|
const {
|
|
70
71
|
gh, link, branch, label = LABEL, stateFile, deps,
|
|
71
|
-
ackMinutes = 10, maxMinutes = 120, maxRuns = 6, maxRunsPerDay = 20, soloGraceMinutes = 2,
|
|
72
|
+
ackMinutes = 10, maxMinutes = 120, maxRuns = 6, maxRunsPerDay = 20, soloGraceMinutes = 2, refusalRetryMinutes = 10,
|
|
72
73
|
fallbackAgent = '', host = os.hostname(),
|
|
73
74
|
now = () => Date.now(), log = () => {},
|
|
74
75
|
} = o
|
|
@@ -100,10 +101,23 @@ export function makeRoomWatcher(o) {
|
|
|
100
101
|
.split(roomId).join('[room id removed]')
|
|
101
102
|
.replace(/wss?:\/\/[^\s|)'"`]+(\|[^\s)'"`]*)?/g, '[relay link removed]')
|
|
102
103
|
.replace(/\broom-[A-Za-z0-9_-]{12,}/g, '[room id removed]')
|
|
104
|
+
// LOCAL PATHS: a refusal's "why" names files on the watcher's machine (a home folder, a temp
|
|
105
|
+
// checkout); the issue is public (0.14.3 P0-2, Claude's review). Not inside URLs.
|
|
106
|
+
.replace(/(?<![\w.:/\\-])(?:[A-Za-z]:[\\/]|\\\\|\/(?:home|Users|tmp|var|root|private|opt|mnt|srv|workspace|data)\/)[^\s'"`)\]]*/g, '[local path removed]')
|
|
103
107
|
const comment = (n, body) => { try { gh.comment(n, scrubRoom(body)) } catch (e) { log(`#${n}: could not comment on GitHub: ${e.message}`) } }
|
|
104
108
|
const today = () => new Date(now()).toISOString().slice(0, 10)
|
|
105
109
|
const runsToday = () => { if (state.day.date !== today()) state.day = { date: today(), runs: 0 }; return state.day.runs }
|
|
106
110
|
|
|
111
|
+
// A room line that may not arrive: deps.say is roomSay, confirmed by a second client. When it is
|
|
112
|
+
// not confirmed (or throws) the watcher logs it and carries on; the caller has already put the
|
|
113
|
+
// same news on GitHub, so nobody is left with neither (0.14.3 P0-2).
|
|
114
|
+
async function sayChecked(n, text) {
|
|
115
|
+
let r = null
|
|
116
|
+
try { r = await deps.say(text) } catch (e) { r = { ok: false, why: e && e.message } }
|
|
117
|
+
if (!r || !r.ok) log(`#${n}: could not confirm the room heard it (${(r && (r.why || r.code)) || 'no answer'}); the GitHub comment stands`)
|
|
118
|
+
return r
|
|
119
|
+
}
|
|
120
|
+
|
|
107
121
|
function needsHuman(n, why, lines = []) {
|
|
108
122
|
const it = state.issues[n]
|
|
109
123
|
if (it) it.status = 'needs-human'
|
|
@@ -180,7 +194,7 @@ export function makeRoomWatcher(o) {
|
|
|
180
194
|
log(`#${n}: withdrawn`)
|
|
181
195
|
return
|
|
182
196
|
}
|
|
183
|
-
const peek = await deps.peek({ issueNumber: n, taskId: a.taskId, since: a.postedAt })
|
|
197
|
+
const peek = await deps.peek({ issueNumber: n, taskId: a.taskId, since: a.postedAt, postFrontier: a.postFrontier || null })
|
|
184
198
|
if (peek && peek.ok && peek.engaged && !a.engagedAt) a.engagedAt = now()
|
|
185
199
|
|
|
186
200
|
// A LONE AGENT CANNOT GET A SECOND AGENT'S REVIEW. When the work is done and the only thing
|
|
@@ -197,9 +211,15 @@ export function makeRoomWatcher(o) {
|
|
|
197
211
|
const solo = (a.agentsSeen || []).length <= 1
|
|
198
212
|
const graceMs = (solo ? soloGraceMinutes : ackMinutes) * 60000
|
|
199
213
|
const allowUnreviewed = !!(a.unreviewedSince && now() - a.unreviewedSince >= graceMs && peek && peek.ok && peek.reachable.length <= 1)
|
|
200
|
-
|
|
214
|
+
// A REFUSAL IS TRIED AGAIN AFTER A PAUSE even when nothing changed (a push answered 503, a lock
|
|
215
|
+
// on the remote): once per refusalRetryMinutes, never every tick, and a repeat of the same
|
|
216
|
+
// refusal is neither said again nor counted again (antigravity-VM's attack 1). Failing tests
|
|
217
|
+
// are not retried this way: they wait for the room to change.
|
|
218
|
+
let lastFp = a.lastFailedFingerprint || ''
|
|
219
|
+
if (lastFp && a.lastRefusal && now() - (a.refusalTriedAt || a.lastRefusal.at) >= refusalRetryMinutes * 60000) { lastFp = ''; a.refusalTriedAt = now() }
|
|
220
|
+
const r = await deps.landIssue({ taskId: a.taskId, issue: { number: n, title: it.title }, lastFailedFingerprint: lastFp, allowUnreviewed })
|
|
201
221
|
if (r && !r.ok && r.blocked === 'unreviewed') {
|
|
202
|
-
if (!a.unreviewedSince) {
|
|
222
|
+
if (!a.unreviewedSince && !a.unreviewedSaid) {
|
|
203
223
|
a.unreviewedSince = now()
|
|
204
224
|
const files = (r.files || []).slice(0, 6).join(', ') || 'the changed files'
|
|
205
225
|
if (solo) {
|
|
@@ -208,8 +228,16 @@ export function makeRoomWatcher(o) {
|
|
|
208
228
|
} else {
|
|
209
229
|
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
230
|
}
|
|
231
|
+
a.unreviewedSaid = true
|
|
211
232
|
}
|
|
212
|
-
|
|
233
|
+
if (!a.unreviewedSince) a.unreviewedSince = now()
|
|
234
|
+
} else if (r && !r.ok && !r.skipped && !r.code && !r.fingerprint) {
|
|
235
|
+
// Only "not ready" (work reopened, a claim taken) restarts the review wait. A refusal or a
|
|
236
|
+
// skipped landing is not a change in the work: resetting here made a refused solo landing
|
|
237
|
+
// re-announce "nobody else to review" every grace period (antigravity-VM's attack 3).
|
|
238
|
+
a.unreviewedSince = 0
|
|
239
|
+
a.unreviewedSaid = false
|
|
240
|
+
}
|
|
213
241
|
if (r && r.state && 'lastFailedFingerprint' in r.state) a.lastFailedFingerprint = r.state.lastFailedFingerprint || ''
|
|
214
242
|
if (r && r.ok && a.kind === 'review') {
|
|
215
243
|
const pr = r.pr || {}
|
|
@@ -234,8 +262,38 @@ export function makeRoomWatcher(o) {
|
|
|
234
262
|
}
|
|
235
263
|
if (r && r.code === 'Q576' && !r.skipped) {
|
|
236
264
|
a.runs = (a.runs || 0) + 1
|
|
265
|
+
a.lastRefusal = null // failing tests wait for the room to change; they are not retried on a timer
|
|
237
266
|
if (a.runs >= maxRuns) return needsHuman(n, `the tests failed ${a.runs} times in a row (the limit is ${maxRuns}).`, (peek && peek.lastLines) || [])
|
|
238
267
|
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 })}`)
|
|
268
|
+
// SAID BY THE WATCHER, CONFIRMED: landIssue's own room line went through a connection that
|
|
269
|
+
// whenDone had already stopped, so the room never heard its tests failed (0.14.3 P0-2).
|
|
270
|
+
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.`)
|
|
271
|
+
}
|
|
272
|
+
// A LANDING REFUSED FOR ANY OTHER REASON IS SAID, never swallowed (0.14.3 P0-2). On 0.14.2 a
|
|
273
|
+
// refusal (Q506, a push refused, no git identity...) matched no branch here: nothing in the
|
|
274
|
+
// room, nothing on the issue, and the next tick's identical fingerprint was a silent skip, so
|
|
275
|
+
// issue #9 sat "done" and landed nothing with no word anywhere. Said once per refusal (same
|
|
276
|
+
// code, same fingerprint), counted like failing tests, then a person. The issue comment goes
|
|
277
|
+
// FIRST: an unreachable room must never mean nobody heard (Claude's review).
|
|
278
|
+
if (r && !r.ok && !r.skipped && r.blocked !== 'unreviewed' && r.code !== 'Q576' && (r.code || r.fingerprint)) {
|
|
279
|
+
const fp = r.fingerprint || (r.state && r.state.lastFailedFingerprint) || ''
|
|
280
|
+
const key = `${r.code || ''}|${fp || oneLine(r.why, 120)}`
|
|
281
|
+
if (a.refusedKey !== key) {
|
|
282
|
+
a.refusedKey = key
|
|
283
|
+
a.refusals = (a.refusals || 0) + 1
|
|
284
|
+
const code = r.code || 'refused'
|
|
285
|
+
const why = oneLine(r.why, 300) || 'no reason given'
|
|
286
|
+
a.lastRefusal = { code, why, at: now() }
|
|
287
|
+
log(`#${n}: landing refused (${code}): ${why}`)
|
|
288
|
+
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) || [])
|
|
289
|
+
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 })}`)
|
|
290
|
+
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.`)
|
|
291
|
+
} else if (a.lastRefusal) {
|
|
292
|
+
// The same refusal again (a retry after the pause): not said or counted again, but
|
|
293
|
+
// `github status` shows it is still happening, so a silent wait is never mistaken for
|
|
294
|
+
// progress (Claude's review).
|
|
295
|
+
a.lastRefusal.seen = now()
|
|
296
|
+
}
|
|
239
297
|
}
|
|
240
298
|
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
299
|
|
|
@@ -338,7 +396,7 @@ export function makeRoomWatcher(o) {
|
|
|
338
396
|
return { postFailed: n }
|
|
339
397
|
}
|
|
340
398
|
it.status = 'active'
|
|
341
|
-
state.active = { n, taskId: p.taskId, postedAt: now(), engagedAt: 0, runs: 0, fallback: null, lastFailedFingerprint: '' }
|
|
399
|
+
state.active = { n, taskId: p.taskId, postedAt: now(), postFrontier: p.postFrontier || null, engagedAt: 0, runs: 0, fallback: null, lastFailedFingerprint: '' }
|
|
342
400
|
// Commands already on the issue (a "/hivecode stop" from before this run) are history: only
|
|
343
401
|
// what is said from now on stops it.
|
|
344
402
|
try { await commands(n, 0) } catch { /* read again next tick */ }
|
|
@@ -395,6 +453,10 @@ export function describeRoomState(s, { now = Date.now(), maxRunsPerDay = (s && s
|
|
|
395
453
|
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
454
|
if (a.fallback) out.push(` fallback agent started ${mins(a.fallback.at)} min ago (${a.fallback.why})`)
|
|
397
455
|
if (a.unreviewedSince) out.push(` done, waiting ${mins(a.unreviewedSince)} min for a review by an agent that did not write it`)
|
|
456
|
+
if (a.lastRefusal) {
|
|
457
|
+
const still = a.lastRefusal.seen ? `; still refused (same reason) for ${mins(a.lastRefusal.at)} min, last tried ${mins(a.lastRefusal.seen)} min ago` : ''
|
|
458
|
+
out.push(` landing refused ${mins(a.lastRefusal.at)} min ago (${a.lastRefusal.code}): ${oneLine(a.lastRefusal.why, 200)}${still}`)
|
|
459
|
+
}
|
|
398
460
|
} else if (s.openPr) {
|
|
399
461
|
out.push(` waiting for PR #${s.openPr} to be merged or closed before the next issue (one open PR per room)`)
|
|
400
462
|
} else {
|
|
@@ -522,7 +584,30 @@ export async function runRoomMode({ f, opt, gh, id, info, root, gitDir, label, l
|
|
|
522
584
|
}
|
|
523
585
|
|
|
524
586
|
/** A passive look at the room: who can hear, and whether anyone has engaged with this issue. */
|
|
525
|
-
|
|
587
|
+
/**
|
|
588
|
+
* Has an agent said something about this issue since the room's post of it?
|
|
589
|
+
*
|
|
590
|
+
* BY IDENTITY WHEN WE CAN (0.14.4). "After the post" by POSITION missed an agent reply that was in
|
|
591
|
+
* flight when the issue was posted: it integrates BEFORE the post and read as "nobody replied". With
|
|
592
|
+
* the frontier the poster held right after posting (postIssue returns it), a reply counts if its Yjs id
|
|
593
|
+
* is not in it, wherever it landed. A run posted by an older build has no frontier and keeps the
|
|
594
|
+
* position rule: lines after the LAST post of this issue (a retry is "Issue #12 (attempt 2): ...").
|
|
595
|
+
*/
|
|
596
|
+
export function agentRepliedSince({ chat, issueNumber, taskId = '', postFrontier = null }) {
|
|
597
|
+
const ref = new RegExp(`#${Number(issueNumber)}(?!\\d)`)
|
|
598
|
+
const isAgent = (m) => m && m.kind !== 'human' && !/^GitHub/.test(String(m.by || ''))
|
|
599
|
+
const replies = (m) => isAgent(m) && (ref.test(String(m.text || '')) || (taskId && String(m.text || '').includes(taskId)))
|
|
600
|
+
if (postFrontier && typeof postFrontier === 'object' && SEEN.isYArray(chat)) {
|
|
601
|
+
return SEEN.itemsOf(chat).some(({ id, value }) => !SEEN.covers(postFrontier, id) && replies(value))
|
|
602
|
+
}
|
|
603
|
+
const list = SEEN.isYArray(chat) ? chat.toArray() : (Array.isArray(chat) ? chat : [])
|
|
604
|
+
const posted = new RegExp(`Issue #${Number(issueNumber)}(?: \\(attempt \\d+\\))?:`)
|
|
605
|
+
let from = -1
|
|
606
|
+
for (let i = list.length - 1; i >= 0; i--) if (/^GitHub/.test(String(list[i].by || '')) && posted.test(String(list[i].text || ''))) { from = i; break }
|
|
607
|
+
return from >= 0 && list.slice(from + 1).some(replies)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export async function peekRoom({ link, issueNumber, taskId, since = 0, postFrontier = null, token = '', timeoutMs = 60000 }) {
|
|
526
611
|
const { relay, room } = parseLink(link)
|
|
527
612
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-room-peek-'))
|
|
528
613
|
const peek = startSync({ relay, room, dir, name: 'GitHub-peek', kind: 'ai', passive: true, syncFiles: false, token, log: () => {} })
|
|
@@ -537,15 +622,9 @@ export async function peekRoom({ link, issueNumber, taskId, since = 0, token = '
|
|
|
537
622
|
const members = peek.members().filter((m) => m && m.name && !/^GitHub/.test(m.name) && m.kind !== 'human' && !m.passive)
|
|
538
623
|
const reachable = members.filter((m) => ACT.describe(activity.get(m.name), { present: true, listener: m.listener, app: m.app, now: Date.now() }).reachable).map((m) => m.name)
|
|
539
624
|
const chat = peek.doc.getArray('chat').toArray()
|
|
540
|
-
// Chat times are clock strings ("21:04:39") with no date, so "since the issue was posted" is
|
|
541
|
-
//
|
|
542
|
-
const
|
|
543
|
-
const isAgent = (m) => m && m.kind !== 'human' && !/^GitHub/.test(String(m.by || ''))
|
|
544
|
-
let from = -1
|
|
545
|
-
// The LAST post of this issue: a retry is posted as "Issue #12 (attempt 2): ...".
|
|
546
|
-
const posted = new RegExp(`Issue #${Number(issueNumber)}(?: \\(attempt \\d+\\))?:`)
|
|
547
|
-
for (let i = chat.length - 1; i >= 0; i--) if (/^GitHub/.test(String(chat[i].by || '')) && posted.test(String(chat[i].text || ''))) { from = i; break }
|
|
548
|
-
const said = from >= 0 && chat.slice(from + 1).some((m) => isAgent(m) && (ref.test(String(m.text || '')) || (taskId && String(m.text || '').includes(taskId))))
|
|
625
|
+
// Chat times are clock strings ("21:04:39") with no date, so "since the issue was posted" is told
|
|
626
|
+
// by Yjs identity when the post's frontier is known, else by position (agentRepliedSince).
|
|
627
|
+
const said = agentRepliedSince({ chat: peek.doc.getArray('chat'), issueNumber, taskId, postFrontier })
|
|
549
628
|
const t = peek.doc.getMap('tasks').get(taskId)
|
|
550
629
|
const claimed = [...peek.doc.getMap('claims').values()].some((c) => c && c.by && Number(c.since || c.at || 0) >= since)
|
|
551
630
|
return {
|
package/hive-seen.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// hive-seen.js -- which chat messages has this reader seen? Answered by IDENTITY, never by position.
|
|
2
|
+
//
|
|
3
|
+
// THE DEFECT THIS EXISTS FOR (9 Nov 2026, found live in room-6UlP…; reproduced by Opus-Cloud and
|
|
4
|
+
// Claude-cloud on real Yjs at d5b6f56, urgent mail missed in 15 of 40 trials). Every "how far have I
|
|
5
|
+
// read" marker in Hivecode was a COUNT or an ARRAY POSITION: the mailbox behind hive_wait/hive_check
|
|
6
|
+
// and the [!] gate, the room read cursors behind hive_delivered, hive-await's cursor file, the stop
|
|
7
|
+
// hook's watermark, hive-notify, the room watcher, the Slack bridge. But a Y.Array is not append-only.
|
|
8
|
+
// A peer that pushed while its update was still in flight (offline, a stuck long-poll outbox, a crash
|
|
9
|
+
// and rejoin) carries its own clock, and Yjs orders concurrent inserts by (clientID, clock), not by
|
|
10
|
+
// arrival -- so the late message integrates BEHIND messages everyone already read. A position reader
|
|
11
|
+
// never looks there again: it re-delivers a shifted old message and silently drops the new one.
|
|
12
|
+
// antigravity-VM's "@Opus-Cloud … env variables" was stamped 10:22, reached the relay at 11:05 and
|
|
13
|
+
// landed at #818, behind 13 messages the whole room had read. Nobody's reader delivered it.
|
|
14
|
+
//
|
|
15
|
+
// THE IDENTITY. Every element of a Y.Array has a global id {client, clock}: the client that inserted
|
|
16
|
+
// it and that client's clock at the insert. It never changes, wherever the element ends up.
|
|
17
|
+
//
|
|
18
|
+
// "SEEN" IS A FRONTIER: per client, the HIGHEST clock this reader has seen. An id is seen iff
|
|
19
|
+
// frontier[client] >= clock. A late element from a client carries a clock above that client's
|
|
20
|
+
// frontier, so it is unseen wherever Yjs places it. A frontier is exact because one client's inserts
|
|
21
|
+
// into one array integrate in clock order: Yjs never surfaces clock k+1 of a client before clock k.
|
|
22
|
+
//
|
|
23
|
+
// NOT A STATE VECTOR. Yjs's own state vector (Y.encodeStateVector) stores the NEXT clock it expects,
|
|
24
|
+
// highest + 1. This stores the highest SEEN. Building one from the other is off by one and drops
|
|
25
|
+
// exactly one message per client, silently -- so nothing here reads or writes a Yjs state vector, and
|
|
26
|
+
// tests/mail-identity-test.mjs pins the difference. (Claude-cloud's review, attack 2.)
|
|
27
|
+
//
|
|
28
|
+
// PRUNED. Every MCP restart is a new Y.Doc clientID, so a long-lived room accumulates writers. A
|
|
29
|
+
// frontier is pruned to clients that still have an undeleted element in the array. That only ever
|
|
30
|
+
// costs a re-read, never a skip: a later element from a pruned client is unseen and is delivered.
|
|
31
|
+
//
|
|
32
|
+
// THE ONE INTERNAL. `itemsOf` walks the array's item list (`yarr._start`, `item.right`) because Yjs
|
|
33
|
+
// has no public API that pairs a value with its id. yjs is pinned in package.json, and the test
|
|
34
|
+
// asserts `itemsOf(a)` yields exactly `a.toArray()` in order, so a yjs change that breaks the walk
|
|
35
|
+
// fails CI instead of silently mis-reading mail.
|
|
36
|
+
|
|
37
|
+
/** Is this a Y.Array (or anything with Yjs item structure), as opposed to a plain JS array? */
|
|
38
|
+
export function isYArray(x) {
|
|
39
|
+
return !!x && !Array.isArray(x) && typeof x.toArray === 'function' && '_start' in x
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The array's elements in order, each with its Yjs id: [{ id: { client, clock }, value }].
|
|
44
|
+
* Deleted elements are skipped, as toArray() skips them. Element k of an item carries clock + k.
|
|
45
|
+
*/
|
|
46
|
+
export function itemsOf(yarr) {
|
|
47
|
+
const out = []
|
|
48
|
+
if (!isYArray(yarr)) return out
|
|
49
|
+
for (let it = yarr._start; it; it = it.right) {
|
|
50
|
+
if (it.deleted || !it.countable) continue
|
|
51
|
+
const vals = it.content.getContent()
|
|
52
|
+
for (let k = 0; k < vals.length; k++) out.push({ id: { client: it.id.client, clock: it.id.clock + k }, value: vals[k] })
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The clients that still have an undeleted element in the array. */
|
|
58
|
+
export function liveClients(yarr) {
|
|
59
|
+
const s = new Set()
|
|
60
|
+
for (const { id } of itemsOf(yarr)) s.add(id.client)
|
|
61
|
+
return s
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** id as a short string, "client:clock" -- what HIVE_CHAT.md carries for readers without Yjs. */
|
|
65
|
+
export const idKey = (id) => `${id.client}:${id.clock}`
|
|
66
|
+
export function parseIdKey(s) {
|
|
67
|
+
const m = /^(\d+):(\d+)$/.exec(String(s || '').trim())
|
|
68
|
+
return m ? { client: Number(m[1]), clock: Number(m[2]) } : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Does a serialized frontier ({ "client": clock }) cover this id? Absent or malformed covers nothing. */
|
|
72
|
+
export function covers(json, id) {
|
|
73
|
+
if (!json || typeof json !== 'object' || !id) return false
|
|
74
|
+
const v = json[String(id.client)]
|
|
75
|
+
return Number.isFinite(Number(v)) && Number(v) >= id.clock
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A mutable frontier. `version` counts real advances, so a publisher can tell "nothing new" from
|
|
80
|
+
* "something new" without comparing two objects.
|
|
81
|
+
*/
|
|
82
|
+
export function makeFrontier(json = null) {
|
|
83
|
+
const m = new Map()
|
|
84
|
+
let version = 0
|
|
85
|
+
const add = (id) => {
|
|
86
|
+
if (!id || !Number.isFinite(id.clock)) return false
|
|
87
|
+
const cur = m.has(id.client) ? m.get(id.client) : -1
|
|
88
|
+
if (id.clock <= cur) return false
|
|
89
|
+
m.set(id.client, id.clock); version++
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
const merge = (other) => {
|
|
93
|
+
if (!other || typeof other !== 'object') return false
|
|
94
|
+
let changed = false
|
|
95
|
+
for (const [c, v] of Object.entries(other)) {
|
|
96
|
+
const client = Number(c), clock = Number(v)
|
|
97
|
+
if (Number.isFinite(client) && Number.isFinite(clock) && add({ client, clock })) changed = true
|
|
98
|
+
}
|
|
99
|
+
return changed
|
|
100
|
+
}
|
|
101
|
+
if (json) merge(json)
|
|
102
|
+
return {
|
|
103
|
+
has: (id) => !!id && m.has(id.client) && m.get(id.client) >= id.clock,
|
|
104
|
+
add,
|
|
105
|
+
merge,
|
|
106
|
+
get version() { return version },
|
|
107
|
+
get size() { return m.size },
|
|
108
|
+
/** Serialize. With `prune` (a Y.Array), keep only clients that still have an element in it. */
|
|
109
|
+
toJSON({ prune = null } = {}) {
|
|
110
|
+
const keep = prune ? liveClients(prune) : null
|
|
111
|
+
const o = {}
|
|
112
|
+
for (const [c, v] of m) if (!keep || keep.has(c)) o[String(c)] = v
|
|
113
|
+
return o
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The frontier that covers the array's first n elements -- the one-time migration from a count. */
|
|
119
|
+
export function frontierOfPrefix(yarr, n) {
|
|
120
|
+
const f = makeFrontier()
|
|
121
|
+
const items = itemsOf(yarr)
|
|
122
|
+
for (let i = 0; i < Math.min(Math.max(0, Number(n) || 0), items.length); i++) f.add(items[i].id)
|
|
123
|
+
return f
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* How many leading elements a frontier covers without a gap. Published beside the frontier as `at`,
|
|
128
|
+
* so a peer on an older build -- which only knows counts -- is never told more than was really read.
|
|
129
|
+
* It can go DOWN when a late element integrates inside the prefix, and that is the honest answer.
|
|
130
|
+
*/
|
|
131
|
+
export function coveredPrefix(yarr, json) {
|
|
132
|
+
let n = 0
|
|
133
|
+
for (const { id } of itemsOf(yarr)) { if (!covers(json, id)) break; n++ }
|
|
134
|
+
return n
|
|
135
|
+
}
|
package/hive-slack.js
CHANGED
|
@@ -31,6 +31,7 @@ import path from 'path'
|
|
|
31
31
|
import WebSocket from 'ws'
|
|
32
32
|
import { startSync, parseLink } from './sync.js'
|
|
33
33
|
import { scrub } from './hive-webhook.js'
|
|
34
|
+
import * as SEEN from './hive-seen.js' // what was already forwarded, by Yjs id (0.14.4)
|
|
34
35
|
import { realpathSync as __hiveRealpath } from 'fs'
|
|
35
36
|
import { resolve as __hiveResolve } from 'path'
|
|
36
37
|
import { fileURLToPath as __hiveFileURLToPath } from 'url'
|
|
@@ -38,6 +39,25 @@ import { fileURLToPath as __hiveFileURLToPath } from 'url'
|
|
|
38
39
|
const DEFAULT_API = 'https://slack.com/api'
|
|
39
40
|
const ROOM_THREAD_KEY = '__room__'
|
|
40
41
|
const CURSOR_KEY = '__cursor__'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The room->Slack observer: hands `onChat` every chat message not yet forwarded, by Yjs id, wherever it
|
|
45
|
+
* integrated (0.14.4; see runBridge for why not by count). `forwarded` is a Y.Map of frontiers keyed by
|
|
46
|
+
* channel, `at` the old count cursor (used to seed a frontier once, when none was saved), `threads` the
|
|
47
|
+
* map that keeps that count for older bridges. Returns the function to pass to chat.observe.
|
|
48
|
+
*/
|
|
49
|
+
export function makeChatForwarder({ chat, forwarded, key, at, threads, onChat }) {
|
|
50
|
+
const saved = forwarded.get(key)
|
|
51
|
+
const fwd = saved && typeof saved === 'object' ? SEEN.makeFrontier(saved) : SEEN.frontierOfPrefix(chat, at)
|
|
52
|
+
return () => {
|
|
53
|
+
const fresh = []
|
|
54
|
+
for (const { id, value } of SEEN.itemsOf(chat)) { if (fwd.has(id)) continue; fwd.add(id); fresh.push(value) }
|
|
55
|
+
if (!fresh.length) return
|
|
56
|
+
onChat(fresh)
|
|
57
|
+
try { forwarded.set(key, fwd.toJSON({ prune: chat })) } catch { }
|
|
58
|
+
try { const n = SEEN.coveredPrefix(chat, fwd.toJSON()); if (n > (Number(threads.get(CURSOR_KEY)) || 0)) threads.set(CURSOR_KEY, n) } catch { }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
41
61
|
const MAX_SEEN_EVENTS = 500
|
|
42
62
|
|
|
43
63
|
export const USAGE = `usage:
|
|
@@ -180,6 +200,28 @@ export function makeQueue({
|
|
|
180
200
|
|
|
181
201
|
export const SLACK_TIMEOUT_MS = 15000
|
|
182
202
|
|
|
203
|
+
// HOW SLACK READS A CALL'S ARGUMENTS (0.14.3 P0-4). Read methods take their arguments only from the
|
|
204
|
+
// query string or a form body: a JSON body is ignored, so on 0.14.2 conversations.info answered
|
|
205
|
+
// invalid_arguments and users.info user_not_found in a real workspace (measured 9 Nov 2026), which
|
|
206
|
+
// broke `slack check`, the --slack-channel preflight, inbound stop/retry and stop verification.
|
|
207
|
+
// Every fake Slack in the tests accepted JSON, so all of it was green. Write methods (chat.*,
|
|
208
|
+
// agents.*, reactions.*...) take JSON and stay JSON: their blocks and metadata are nested objects.
|
|
209
|
+
const FORM_METHOD = /^(?:(?:conversations|users|team|bots)\.[A-Za-z.]+|auth\.test)$/
|
|
210
|
+
export function slackEncoding(method) {
|
|
211
|
+
return FORM_METHOD.test(String(method || '')) ? 'form' : 'json'
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A form body Slack reads: undefined and null are LEFT OUT (never the text "undefined", which Slack
|
|
215
|
+
* would read as a real channel or user id); a nested value is JSON inside its field, as Slack takes it. */
|
|
216
|
+
export function slackForm(body = {}) {
|
|
217
|
+
const p = new URLSearchParams()
|
|
218
|
+
for (const [k, v] of Object.entries(body || {})) {
|
|
219
|
+
if (v === undefined || v === null) continue
|
|
220
|
+
p.append(k, typeof v === 'object' ? JSON.stringify(v) : String(v))
|
|
221
|
+
}
|
|
222
|
+
return p.toString()
|
|
223
|
+
}
|
|
224
|
+
|
|
183
225
|
/** POST to Slack's Web API. Kept tiny and injectable so the bridge can be tested without a
|
|
184
226
|
* workspace -- the thing worth testing is our routing, not Slack's HTTP.
|
|
185
227
|
* Applies a 15s AbortSignal timeout by default so a hung TCP socket never stalls the queue. */
|
|
@@ -191,10 +233,14 @@ export async function slackPost(
|
|
|
191
233
|
apiBase = process.env.HIVE_SLACK_API || DEFAULT_API,
|
|
192
234
|
timeoutMs = Number(process.env.HIVE_SLACK_TIMEOUT_MS) || SLACK_TIMEOUT_MS,
|
|
193
235
|
) {
|
|
236
|
+
const form = slackEncoding(method) === 'form'
|
|
194
237
|
const r = await fetchImpl(`${apiBase}/${method}`, {
|
|
195
238
|
method: 'POST',
|
|
196
|
-
headers: {
|
|
197
|
-
|
|
239
|
+
headers: {
|
|
240
|
+
'Content-Type': form ? 'application/x-www-form-urlencoded; charset=utf-8' : 'application/json; charset=utf-8',
|
|
241
|
+
Authorization: `Bearer ${token}`,
|
|
242
|
+
},
|
|
243
|
+
body: form ? slackForm(body) : JSON.stringify(body),
|
|
198
244
|
signal: AbortSignal.timeout(timeoutMs),
|
|
199
245
|
})
|
|
200
246
|
const retryHeader = r.headers && typeof r.headers.get === 'function' ? Number(r.headers.get('retry-after') || 0) : 0
|
|
@@ -2120,6 +2166,13 @@ export async function runSlackCheck({
|
|
|
2120
2166
|
clearTimeout(waitTimer)
|
|
2121
2167
|
|
|
2122
2168
|
if (!arrived) {
|
|
2169
|
+
// Over HTTPS polling nothing is subscribed: the bridge reads the thread itself, and its read
|
|
2170
|
+
// scopes were checked above. So no reply means nobody replied in time, and sending the person
|
|
2171
|
+
// off to reinstall the app was wrong (9 Nov, the first real check: 0 replies in the thread).
|
|
2172
|
+
if (pollInbound) {
|
|
2173
|
+
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).`)
|
|
2174
|
+
return 1
|
|
2175
|
+
}
|
|
2123
2176
|
const manifestName = agent ? 'slack-app-manifest-agent.json' : 'slack-app-manifest-inbound.json'
|
|
2124
2177
|
logErr(`Q634 replies did not arrive. Event subscriptions are probably missing: reinstall from ${manifestName}`)
|
|
2125
2178
|
return 1
|
|
@@ -2363,8 +2416,13 @@ export async function main(argv = process.argv.slice(2), { log = console.log, lo
|
|
|
2363
2416
|
}
|
|
2364
2417
|
const chat = hive.doc.getArray('chat')
|
|
2365
2418
|
const savedCursor = Number(bridge.threads.get(CURSOR_KEY)) || 0
|
|
2366
|
-
|
|
2367
|
-
|
|
2419
|
+
const at = savedCursor > 0 && savedCursor <= chat.length ? savedCursor : chat.length
|
|
2420
|
+
// FORWARDED BY IDENTITY, NOT BY COUNT (0.14.4). `all.slice(at)` never looked behind `at`, so a message
|
|
2421
|
+
// a peer pushed while its update was in flight -- which integrates BEHIND what was already counted --
|
|
2422
|
+
// was never sent to Slack (hive-seen.js has the incident). What was forwarded is now a frontier of
|
|
2423
|
+
// Yjs ids, kept in its own room map: the thread map's other keys are Slack thread ids, and an older
|
|
2424
|
+
// bridge would try to poll any new key there as a thread. The count stays beside it, for those.
|
|
2425
|
+
chat.observe(makeChatForwarder({ chat, forwarded: hive.doc.getMap('slack_forwarded'), key: String(CHANNEL), at, threads: bridge.threads, onChat: bridge.onChat }))
|
|
2368
2426
|
await bridge.watchRoom()
|
|
2369
2427
|
log(`READY: [hive-slack] @${pre.botUser} bridging ${room} -> #${pre.channelName} (${CHANNEL}) — resume: ${allowParsed.ids.length ? allowParsed.ids.join(', ') : 'off'}`)
|
|
2370
2428
|
return 0
|
package/hive-stop-hook.js
CHANGED
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
*/
|
|
56
56
|
import fs from 'fs'
|
|
57
57
|
import path from 'path'
|
|
58
|
+
import crypto from 'crypto'
|
|
58
59
|
import { mentionsName, unansweredFor } from './hive-mailbox.js'
|
|
59
60
|
import { isArmed, listenCommand, listenHow, listenOrder } from './hive-listen.js'
|
|
60
61
|
import { readAgents, agentForHook } from './hive-session.js'
|
|
@@ -128,32 +129,46 @@ export function decide(o) {
|
|
|
128
129
|
return { ...d, block: true, reason: d.block ? `${d.reason}\n\n${listenLine}` : listenLine, listenBlocks: listenBlocks + 1 }
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
|
|
132
|
+
// WHICH MENTIONS WERE ALREADY BLOCKED ON, BY IDENTITY, NOT BY LINE (0.14.4). The watermark was a line
|
|
133
|
+
// count, `seen`: everything after line N was new. But the room's chat is a Y.Array, and a message a
|
|
134
|
+
// peer pushed while its update was in flight integrates at its ORIGINAL position, behind lines already
|
|
135
|
+
// counted -- so a late mention was rendered mid-file and this hook never blocked on it (hive-seen.js
|
|
136
|
+
// has the incident). This file has no Yjs, so a mention is identified by what HIVE_CHAT.md carries:
|
|
137
|
+
// author, time and text. The only messages that can share that are exact duplicates -- the same author
|
|
138
|
+
// saying the same words in the same second -- and blocking once for both is the right answer anyway.
|
|
139
|
+
const fingerprint = (m) => crypto.createHash('sha1').update(`${m.by}\u0000${m.at}\u0000${m.text}`).digest('hex').slice(0, 16)
|
|
140
|
+
|
|
141
|
+
function obligations({ chat, tasks, me, seen, handled = null, taskBlocks, nudged = [] }) {
|
|
132
142
|
const msgs = parseChat(chat)
|
|
133
143
|
const nowSeen = msgs.length
|
|
134
|
-
|
|
135
|
-
// rather than treating the whole history as unread and blocking on all of it.
|
|
136
|
-
const from = seen > nowSeen ? 0 : seen
|
|
137
|
-
const fresh = msgs.slice(from)
|
|
138
|
-
|
|
139
|
-
const mail = fresh.filter((m) =>
|
|
144
|
+
const isMail = (m) =>
|
|
140
145
|
m.kind !== 'system' &&
|
|
141
146
|
String(m.by).toLowerCase() !== String(me).toLowerCase() && // never block on our own post
|
|
142
|
-
mentionsName(m.text, me)
|
|
147
|
+
mentionsName(m.text, me)
|
|
148
|
+
// `handled` (0.14.4): the fingerprints of every mention already blocked on. A state written by an
|
|
149
|
+
// older build has only the count; it is migrated once, as the prefix it described. A count past the
|
|
150
|
+
// end means a different (rebuilt or switched) room: start over rather than block on its history.
|
|
151
|
+
let done
|
|
152
|
+
if (Array.isArray(handled)) done = new Set(handled)
|
|
153
|
+
else done = new Set(msgs.slice(0, seen > nowSeen ? 0 : seen).filter(isMail).map(fingerprint))
|
|
154
|
+
const mail = msgs.filter((m) => isMail(m) && !done.has(fingerprint(m)))
|
|
155
|
+
// After this decision every mention in the file is handled: blocked on now, or before. Kept to what
|
|
156
|
+
// the file holds, so it is bounded by the room's history and a rebuilt room sheds the old ones.
|
|
157
|
+
const nextHandled = [...new Set(msgs.filter(isMail).map(fingerprint))]
|
|
143
158
|
|
|
144
159
|
if (mail.length) {
|
|
145
160
|
const quoted = mail.slice(0, 5).map((m) => ` ${m.by}: ${m.text.replace(/\s+/g, ' ').slice(0, 220)}`)
|
|
146
161
|
const more = mail.length > 5 ? `\n ... and ${mail.length - 5} more` : ''
|
|
147
162
|
return {
|
|
148
|
-
block: true, seen: nowSeen, taskBlocks: 0,
|
|
163
|
+
block: true, seen: nowSeen, handled: nextHandled, taskBlocks: 0,
|
|
149
164
|
// Blocking here also spends this message's ONE nudge, so the debt branch below cannot
|
|
150
165
|
// charge for the same message a second time. One block per message, whatever the reason.
|
|
151
166
|
nudged: [...nudged, ...mail.map((m) => m.by + '|' + m.at)].slice(-200),
|
|
152
167
|
reason: `You are in a Hivecode room and ${mail.length} message${mail.length > 1 ? 's' : ''} ` +
|
|
153
168
|
`addressed to you arrived while you were working:\n\n${quoted.join('\n')}${more}\n\n` +
|
|
154
169
|
`Do not stop -- they are waiting on a reply from you. Answer with hive_say (put @Name on ` +
|
|
155
|
-
`it), then
|
|
156
|
-
`
|
|
170
|
+
`it), then stay reachable the way this client does it (your listener, or hive_wait): a ` +
|
|
171
|
+
`participant who stops hearing still looks present, and nobody in the room finds out.`,
|
|
157
172
|
}
|
|
158
173
|
}
|
|
159
174
|
|
|
@@ -172,7 +187,7 @@ function obligations({ chat, tasks, me, seen, taskBlocks, nudged = [] }) {
|
|
|
172
187
|
if (unnudged.length) {
|
|
173
188
|
const quoted = unnudged.slice(0, 3).map((o) => ` ${o.from}: ${o.text.replace(/\s+/g, ' ').slice(0, 200)}`)
|
|
174
189
|
return {
|
|
175
|
-
block: true, seen: nowSeen, taskBlocks,
|
|
190
|
+
block: true, seen: nowSeen, handled: nextHandled, taskBlocks,
|
|
176
191
|
// Recorded BEFORE returning, exactly as the watermark is, so a question cannot block a
|
|
177
192
|
// second time even if whatever happens next goes wrong. Capped so the state file cannot
|
|
178
193
|
// grow without bound in a long-lived room.
|
|
@@ -181,7 +196,7 @@ function obligations({ chat, tasks, me, seen, taskBlocks, nudged = [] }) {
|
|
|
181
196
|
`still waiting on a reply from you:\n\n${quoted.join('\n')}\n\n` +
|
|
182
197
|
`Answer with hive_say and name them. "Not yet — I am on X, I will come back to you" is a ` +
|
|
183
198
|
`complete answer; silence is the one thing they cannot act on, and nothing tells them you ` +
|
|
184
|
-
`stopped. Then
|
|
199
|
+
`stopped. Then stay reachable (your listener, or hive_wait).`,
|
|
185
200
|
}
|
|
186
201
|
}
|
|
187
202
|
|
|
@@ -189,11 +204,11 @@ function obligations({ chat, tasks, me, seen, taskBlocks, nudged = [] }) {
|
|
|
189
204
|
if (open.length && taskBlocks < MAX_TASK_BLOCKS) {
|
|
190
205
|
const quoted = open.slice(0, 3).map((t) => ` [${t.id}] from ${t.from}: ${t.text.replace(/\s+/g, ' ').slice(0, 220)}`)
|
|
191
206
|
return {
|
|
192
|
-
block: true, seen: nowSeen, taskBlocks: taskBlocks + 1, nudged,
|
|
207
|
+
block: true, seen: nowSeen, handled: nextHandled, taskBlocks: taskBlocks + 1, nudged,
|
|
193
208
|
reason: `You are in a Hivecode room with ${open.length} accepted task${open.length > 1 ? 's' : ''} ` +
|
|
194
209
|
`still open:\n\n${quoted.join('\n')}\n\n` +
|
|
195
210
|
`Finish the work and call hive_complete with the task id, or say why you are blocked with ` +
|
|
196
|
-
`hive_say (@ the person who assigned it). Then
|
|
211
|
+
`hive_say (@ the person who assigned it). Then stay reachable. If you genuinely cannot ` +
|
|
197
212
|
`proceed, say so in the room first -- do not just stop, nobody would learn that you had.`,
|
|
198
213
|
}
|
|
199
214
|
}
|
|
@@ -202,7 +217,7 @@ function obligations({ chat, tasks, me, seen, taskBlocks, nudged = [] }) {
|
|
|
202
217
|
// as it being resolved, so hold the counter rather than resetting it while it is still there.
|
|
203
218
|
// Debts and tasks share the counter deliberately: they are the same kind of obligation, and
|
|
204
219
|
// two separate counters would let an agent be pinned for 2x MAX_TASK_BLOCKS turns.
|
|
205
|
-
return { block: false, seen: nowSeen, taskBlocks: open.length ? taskBlocks : 0, nudged }
|
|
220
|
+
return { block: false, seen: nowSeen, handled: nextHandled, taskBlocks: open.length ? taskBlocks : 0, nudged }
|
|
206
221
|
}
|
|
207
222
|
|
|
208
223
|
/** Walk up from `start` looking for the session file; the hook's cwd is usually but not always
|
|
@@ -266,7 +281,7 @@ async function main() {
|
|
|
266
281
|
|
|
267
282
|
const d = decide({
|
|
268
283
|
chat, tasks: read('HIVE_TASKS.md'), me: session.name,
|
|
269
|
-
seen: Number(st.seen) || 0, taskBlocks: Number(st.taskBlocks) || 0,
|
|
284
|
+
seen: Number(st.seen) || 0, handled: Array.isArray(st.handled) ? st.handled : null, taskBlocks: Number(st.taskBlocks) || 0,
|
|
270
285
|
nudged: Array.isArray(st.nudged) ? st.nudged : [],
|
|
271
286
|
listening, listenLine, listenBlocks: Number(st.listenBlocks) || 0,
|
|
272
287
|
})
|
|
@@ -274,7 +289,7 @@ async function main() {
|
|
|
274
289
|
// GUARD 2: write the watermark BEFORE returning the block, so this message can never block a
|
|
275
290
|
// second time even if what happens next goes wrong.
|
|
276
291
|
try {
|
|
277
|
-
prev[key] = { seen: d.seen, taskBlocks: d.taskBlocks, nudged: d.nudged || [], listenBlocks: d.listenBlocks, at: Date.now() }
|
|
292
|
+
prev[key] = { seen: d.seen, handled: d.handled, taskBlocks: d.taskBlocks, nudged: d.nudged || [], listenBlocks: d.listenBlocks, at: Date.now() }
|
|
278
293
|
fs.writeFileSync(statePath, JSON.stringify(prev, null, 2))
|
|
279
294
|
} catch { /* a watermark we cannot persist is a reason to allow, never to fail */ }
|
|
280
295
|
|