thinkpool-pair 0.7.153 → 0.7.154
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/bridge.mjs +9 -5
- package/cross-terminal.mjs +29 -9
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -81,7 +81,7 @@ const flowRedispatch = new Map()
|
|
|
81
81
|
// wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
|
|
82
82
|
// broadcasts; without persistent state the cap can never bite.
|
|
83
83
|
const flowBudgets = new Map()
|
|
84
|
-
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow } from './cross-terminal.mjs'
|
|
84
|
+
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow } from './cross-terminal.mjs'
|
|
85
85
|
import { turnInFlight } from './update-gate.mjs'
|
|
86
86
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, loadSummary, saveSummary } from './session-store.mjs'
|
|
87
87
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks } from './event-id.mjs'
|
|
@@ -1593,16 +1593,20 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1593
1593
|
},
|
|
1594
1594
|
async (args) => {
|
|
1595
1595
|
const okText = (t) => ({ content: [{ type: 'text', text: t }] })
|
|
1596
|
+
const now = Date.now()
|
|
1596
1597
|
const gate = spawnDecision({
|
|
1597
1598
|
hop: entry.hop || 0,
|
|
1598
|
-
|
|
1599
|
+
spawnTimes: entry.spawnTimes || [], // sliding-window burst breaker (replenishes on wall-clock, not on a human turn)
|
|
1600
|
+
now,
|
|
1599
1601
|
spawnedLive: [...sessions.values()].filter((s) => s.spawnedBy).length, // dispatch budget counts ONLY spawned lanes
|
|
1600
1602
|
totalLive: sessions.size + terms.size, // machine cap counts everything
|
|
1601
1603
|
plan: ownerPlan, // Free 3 / Plus 6 dispatch ceiling
|
|
1602
1604
|
disabled: process.env.TP_SPAWN_OFF === '1',
|
|
1603
1605
|
})
|
|
1604
1606
|
if (!gate.ok) return okText(gate.reason)
|
|
1605
|
-
|
|
1607
|
+
// Record this spawn, pruning timestamps that have already left the window
|
|
1608
|
+
// (identical predicate to recentSpawnCount) so the array can't grow unbounded.
|
|
1609
|
+
entry.spawnTimes = [...(entry.spawnTimes || []).filter((t) => Number.isFinite(t) && t > now - SPAWN.windowMs), now]
|
|
1606
1610
|
const newId = randomUUID()
|
|
1607
1611
|
const newRef = String(newId).slice(0, 8)
|
|
1608
1612
|
const fromRef = String(id).slice(0, 8)
|
|
@@ -1623,7 +1627,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1623
1627
|
if (args?.task) {
|
|
1624
1628
|
// One hop deep: the spawned lane cannot spawn/post onward until a person
|
|
1625
1629
|
// speaks to it (code-turn resets hop to 0). Mirrors the Tier C injection.
|
|
1626
|
-
ne.hop = 1; ne.peekCount = 0; ne.postCount = 0; ne.
|
|
1630
|
+
ne.hop = 1; ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
|
|
1627
1631
|
const msg = `[Task from terminal ${fromRef}'s agent — relayed via ThinkPool cross-terminal; you are a fresh lane it opened for you]\n${args.task}`
|
|
1628
1632
|
const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true }
|
|
1629
1633
|
stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
|
|
@@ -2266,7 +2270,7 @@ channel
|
|
|
2266
2270
|
s.postCount = 0
|
|
2267
2271
|
s.pairPeekCount = 0 // cross-room read budget resets with the in-room ones (Tier 1)
|
|
2268
2272
|
s.crossRoomPostCount = 0 // Tier 3 cross-room post budget resets on a real human turn
|
|
2269
|
-
s.
|
|
2273
|
+
s.spawnTimes = [] // EN-M3 — a real human turn clears the spawn burst window instantly (the window also self-replenishes on wall-clock for UNATTENDED cascades that never reach this handler)
|
|
2270
2274
|
s.hop = 0
|
|
2271
2275
|
s.roomHop = 0 // a human turn is room-hop 0 — clears any injected cross-room hop depth
|
|
2272
2276
|
const text = String(payload.text)
|
package/cross-terminal.mjs
CHANGED
|
@@ -227,15 +227,25 @@ export const crossPostNeedsCard = ({ mode = 'default', spawnedByMe = false } = {
|
|
|
227
227
|
// own terminals, and the human's terminals never eat the dispatch budget. Under
|
|
228
228
|
// BYOK-only the tokens are the user's, so this is feature-shaping + machine
|
|
229
229
|
// safety, NOT a ThinkPool-COGS control.
|
|
230
|
-
// •
|
|
230
|
+
// • burst window — a SLIDING-window runaway breaker: ≤ perWindowCap spawns per
|
|
231
|
+
// rolling windowMs. Replaces the old per-HUMAN-turn cap, which starved an
|
|
232
|
+
// UNATTENDED conductor: agent turns woken by Monitor/task notifications never
|
|
233
|
+
// traverse the 'code-turn' handler that reset the per-turn counter, so a cascade
|
|
234
|
+
// that spent its 4 spawns could NEVER spawn again until a person typed. The
|
|
235
|
+
// window replenishes on wall-clock alone — no turn boundary required — while a
|
|
236
|
+
// real human turn still clears it instantly (the reset point is unchanged).
|
|
231
237
|
// The caller measures: spawnedLive (live sessions with spawnedBy set), totalLive
|
|
232
|
-
// (sessions.size + terms.size),
|
|
238
|
+
// (sessions.size + terms.size), resolves `plan` from the room owner, and passes the
|
|
239
|
+
// lane's recent spawn timestamps (`spawnTimes`) plus the current clock (`now`) — so
|
|
240
|
+
// this stays a PURE function (no Date.now inside; the clock is injected, like
|
|
241
|
+
// spawnCount was before).
|
|
233
242
|
export const SPAWN = {
|
|
234
|
-
maxHop: 1,
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
243
|
+
maxHop: 1, // human turn = hop 0; a spawned/injected lane = hop ≥ 1, CANNOT spawn onward
|
|
244
|
+
perWindowCap: 4, // spawn_terminal calls allowed per rolling window (burst breaker)
|
|
245
|
+
windowMs: 10 * 60 * 1000, // sliding window over which perWindowCap applies (10 min)
|
|
246
|
+
freeMaxSpawned: 3, // Free plan dispatch ceiling (concurrent spawned lanes)
|
|
247
|
+
plusMaxSpawned: 6, // Plus plan dispatch ceiling
|
|
248
|
+
machineMax: 10, // hard cap on TOTAL live lanes (the laptop/runaway backstop)
|
|
239
249
|
}
|
|
240
250
|
|
|
241
251
|
// Plan → dispatch ceiling. Anything not 'plus' is treated as Free (the safe default).
|
|
@@ -244,14 +254,24 @@ export const maxSpawnedFor = (plan, limits = SPAWN) => {
|
|
|
244
254
|
return plan === 'plus' ? o.plusMaxSpawned : o.freeMaxSpawned
|
|
245
255
|
}
|
|
246
256
|
|
|
247
|
-
|
|
257
|
+
// Count spawn timestamps still inside the sliding window ending at `now`. Timestamps
|
|
258
|
+
// strictly older than `now - windowMs` have replenished (fallen out of the window).
|
|
259
|
+
// Non-finite entries are ignored defensively. Exported so the bridge can prune its
|
|
260
|
+
// stored spawnTimes with the identical predicate (no unbounded growth).
|
|
261
|
+
export const recentSpawnCount = (spawnTimes, now, windowMs = SPAWN.windowMs) =>
|
|
262
|
+
(Array.isArray(spawnTimes) ? spawnTimes : []).filter((t) => Number.isFinite(t) && t > now - windowMs).length
|
|
263
|
+
|
|
264
|
+
export const spawnDecision = ({ hop = 0, spawnTimes = [], now = 0, spawnedLive = 0, totalLive = 0, plan = 'free', disabled = false } = {}, limits = SPAWN) => {
|
|
248
265
|
const o = { ...SPAWN, ...(limits || {}) }
|
|
249
266
|
if (disabled) return { ok: false, reason: 'Spawning terminals is disabled in this room.' }
|
|
250
267
|
if (hop >= o.maxHop) return { ok: false, reason: 'Spawn limit reached — a terminal reached via cross-terminal cannot itself open further terminals; a person must open the next lane.' }
|
|
251
268
|
if (totalLive >= o.machineMax) return { ok: false, reason: `Room is at the machine cap (${o.machineMax} live lanes) — close one before opening another.` }
|
|
252
269
|
const cap = maxSpawnedFor(plan, o)
|
|
253
270
|
if (spawnedLive >= cap) return { ok: false, reason: `Dispatch ceiling reached (${cap} lane${cap === 1 ? '' : 's'} on the ${plan} plan)${plan !== 'plus' ? ` — Plus raises it to ${o.plusMaxSpawned}.` : '.'} Close a dispatched lane to open another.` }
|
|
254
|
-
if (
|
|
271
|
+
if (recentSpawnCount(spawnTimes, now, o.windowMs) >= o.perWindowCap) {
|
|
272
|
+
const mins = Math.round(o.windowMs / 60000)
|
|
273
|
+
return { ok: false, reason: `Spawn burst limit reached (${o.perWindowCap} in ${mins} min) — this replenishes as the window slides forward, or instantly when a person types into this lane.` }
|
|
274
|
+
}
|
|
255
275
|
return { ok: true }
|
|
256
276
|
}
|
|
257
277
|
|