thinkpool-pair 0.7.358 → 0.7.360
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/account.mjs +3 -1
- package/bridge.mjs +289 -17
- package/package.json +3 -1
- package/scheduled-run-admission.mjs +364 -0
- package/scheduled-runs.mjs +268 -0
- package/service.mjs +10 -4
- package/session-store.mjs +117 -0
- package/thinkpool-capabilities.json +8 -6
- package/viewport.mjs +26 -14
package/account.mjs
CHANGED
|
@@ -798,6 +798,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
798
798
|
// reap that lost room XE3IQ's history.
|
|
799
799
|
// Spec: docs/specs/2026-06-25-single-bridge-durable-history.md (INV-2)
|
|
800
800
|
let claimHeld = false
|
|
801
|
+
let claimFencing = 0
|
|
801
802
|
let standbyHost = null
|
|
802
803
|
// Wall-clock of the last SUCCESSFUL claim RPC. The heartbeat watchdog (below) uses it to
|
|
803
804
|
// detect a wedged loop and self-heal — the 2026-07-08 outage stood by against its OWN
|
|
@@ -822,6 +823,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
822
823
|
else if (!held && claimHeld) process.stderr.write(`\n ◇ another device (${host}) took over this account — standing by.\n`)
|
|
823
824
|
else if (!held && standbyHost !== host) process.stderr.write(`\n ◇ another device (${host}) is serving this account — standing by.\n (this Mac takes over automatically if that device goes offline.)\n`)
|
|
824
825
|
claimHeld = held
|
|
826
|
+
claimFencing = held && Number.isSafeInteger(Number(c?.fencing)) && Number(c.fencing) > 0 ? Number(c.fencing) : 0
|
|
825
827
|
standbyHost = held ? null : host
|
|
826
828
|
|
|
827
829
|
// ── supervisor claim_tick logging (cascade brg-instrument) ─────────────────
|
|
@@ -1026,7 +1028,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
1026
1028
|
// @latest); they report idle to us over IPC so we can restart at a moment
|
|
1027
1029
|
// when every session is quiet. Strip AUTOUPDATE so an inherited service env
|
|
1028
1030
|
// can't make a child self-exit; flag it as an account child instead.
|
|
1029
|
-
const env = { ...process.env, THINKPOOL_PAIR_ACCOUNT_CHILD: '1' }
|
|
1031
|
+
const env = { ...process.env, THINKPOOL_PAIR_ACCOUNT_CHILD: '1', TP_ACCOUNT_BRIDGE_ID: BRIDGE_ID, TP_ACCOUNT_BRIDGE_FENCING: String(claimFencing) }
|
|
1030
1032
|
delete env.THINKPOOL_PAIR_AUTOUPDATE
|
|
1031
1033
|
// Hand the child the owner JWT for authed web writes (code-mockup, L16).
|
|
1032
1034
|
if (currentAccessToken) env.TP_ACCESS_TOKEN = currentAccessToken
|
package/bridge.mjs
CHANGED
|
@@ -129,11 +129,13 @@ import { createStandalonePairResponder, standalonePairIdentity } from './direct-
|
|
|
129
129
|
import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
|
|
130
130
|
import { supersedeDispatchLease } from './dispatch-lease.mjs'
|
|
131
131
|
import { realtimeRecoveryDecision, turnInFlight } from './update-gate.mjs'
|
|
132
|
-
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
132
|
+
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage, acknowledgePendingScheduledOutcome, commitRecordedScheduledOutcome, deletePendingScheduledOutcome, loadPendingScheduledOutcome, loadPendingScheduledOutcomes, savePendingScheduledOutcome } from './session-store.mjs'
|
|
133
133
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, appendCurrentPersonRequest, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
134
134
|
import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
|
|
135
135
|
import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
|
|
136
136
|
import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
|
|
137
|
+
import { finishScheduledRunWithRetry, pollDueScheduledRuns, sanitizeScheduledOutcomeIntent, scheduledOutcomeReconciliationComplete, scheduledRunDeadlineRequired, scheduledRunSpawnAdmission, scheduledRunsCapability, scheduledRunsEnabled } from './scheduled-runs.mjs'
|
|
138
|
+
import { acquireScheduledRunAdmission, releaseScheduledRunAdmission } from './scheduled-run-admission.mjs'
|
|
137
139
|
import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, dispatchSideContexts, sideContextBlock, sideSnapshot } from './side-lane.mjs'
|
|
138
140
|
import { acknowledgeWorkerCompletions, enqueueWorkerCompletion, workerCompletionPrompt, workerCompletionRecord } from './worker-completion.mjs'
|
|
139
141
|
import { planMeterLine } from './plan-meters.mjs'
|
|
@@ -664,6 +666,11 @@ if (process.stdin.isTTY && !headless && process.env.THINKPOOL_PAIR_AUTOUPDATE !=
|
|
|
664
666
|
}
|
|
665
667
|
const name = process.env.TP_NAME || os.userInfo().username || 'host'
|
|
666
668
|
const BRIDGE_ID = (randomUUID?.().slice(0,8)) || 'bridgexx'
|
|
669
|
+
// The room child has its own short local id for terminal/pair-control authority.
|
|
670
|
+
// Scheduled execution additionally needs the supervisor's UUID claim, inherited only
|
|
671
|
+
// by account-owned children; standalone/anon bridges deliberately cannot run schedules.
|
|
672
|
+
const ACCOUNT_BRIDGE_ID = process.env.TP_ACCOUNT_BRIDGE_ID || null
|
|
673
|
+
const ACCOUNT_BRIDGE_FENCING = Number.parseInt(process.env.TP_ACCOUNT_BRIDGE_FENCING || '', 10)
|
|
667
674
|
const BRIDGE_STARTED_AT = Date.now()
|
|
668
675
|
// host: this machine's short label — os.hostname() with any DNS/domain suffix
|
|
669
676
|
// stripped and capped ~24 chars. A /code room can be served by different bridges
|
|
@@ -1392,6 +1399,9 @@ const announce = () => {
|
|
|
1392
1399
|
// updir: where room file-drops land (forward-slash normalised — the web
|
|
1393
1400
|
// client string-joins host paths onto it; Node accepts `/` on Windows).
|
|
1394
1401
|
updir: UPDIR.split(path.sep).join('/'),
|
|
1402
|
+
// Secret-free host opt-in projection for Automations. Never announce auth,
|
|
1403
|
+
// fencing, credentials, endpoints, prompts, or schedule contents.
|
|
1404
|
+
scheduledRuns: scheduledRunsCapability(),
|
|
1395
1405
|
// canResume: this agent can continue a prior session in THIS cwd —
|
|
1396
1406
|
// re-probed per announce (a fresh run creates a session, so the flag
|
|
1397
1407
|
// can flip true while the bridge is up). Functions don't survive
|
|
@@ -1414,7 +1424,7 @@ const announce = () => {
|
|
|
1414
1424
|
// laneStatusOf: authoritative busy/idle + last-action timestamp/age +
|
|
1415
1425
|
// STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
|
|
1416
1426
|
// every roster consumer reads one status instead of reconstructing it.
|
|
1417
|
-
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), turnRev: Number(s._turnRev) || 0, ...(laneBusyOf(s) && s._turnStart ? { turnStartedAt: s._turnStart } : {}), ...(!laneBusyOf(s) && Number(s._settledTurnRev) > 0 && Number(s._settledTurnRev) === Number(s._turnRev) ? { settledTurnRev: Number(s._settledTurnRev), turnSettledAt: Number(s._turnSettledAt) || undefined } : {}), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: { ...structuredRuntimeMetadata(s.runtime || 'claude'), modes: structuredModesForLane(s.runtime || 'claude', s) }, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
|
|
1427
|
+
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), turnRev: Number(s._turnRev) || 0, ...(laneBusyOf(s) && s._turnStart ? { turnStartedAt: s._turnStart } : {}), ...(!laneBusyOf(s) && Number(s._settledTurnRev) > 0 && Number(s._settledTurnRev) === Number(s._turnRev) ? { settledTurnRev: Number(s._settledTurnRev), turnSettledAt: Number(s._turnSettledAt) || undefined } : {}), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: { ...structuredRuntimeMetadata(s.runtime || 'claude'), modes: structuredModesForLane(s.runtime || 'claude', s) }, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}), ...(s.scheduleRunId ? { scheduleRunId: s.scheduleRunId } : {}),
|
|
1418
1428
|
// provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
|
|
1419
1429
|
// (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
|
|
1420
1430
|
// built-in/default Claude path (no badge). Makes the lane's provider badge +
|
|
@@ -1568,7 +1578,10 @@ function attachDurablePermissionSource(entry, payload) {
|
|
|
1568
1578
|
}
|
|
1569
1579
|
pushLog(entry, event)
|
|
1570
1580
|
bcast('code-event', { term: entry.id, evt: event })
|
|
1571
|
-
|
|
1581
|
+
// Schedule provenance is opaque and bounded; the browser's existing durable
|
|
1582
|
+
// pair-control projection carries it into request_context. It is never prompt,
|
|
1583
|
+
// path, credential, or tool argument data.
|
|
1584
|
+
return { ...payload, ...(entry.scheduleRunId ? { runId: entry.scheduleRunId } : {}), source: { epoch: 0, cid: event.cid, seq: event.seq } }
|
|
1572
1585
|
}
|
|
1573
1586
|
// ── Tier 3 — raise a standalone permission card on a target session ──────────
|
|
1574
1587
|
// The cross-ROOM post lands here on the RECEIVING side: a person in THIS room must
|
|
@@ -1755,6 +1768,144 @@ const designArtifacts = new Map() // previewId → trusted source record
|
|
|
1755
1768
|
const designQueues = new Map() // producer term → validated requests
|
|
1756
1769
|
const designActive = new Map() // producer term → request awaiting result + proof
|
|
1757
1770
|
const designRestore = new Map() // current previewId → immediately previous verified record/edit
|
|
1771
|
+
function scheduledEvidenceForTerm(terminalId) {
|
|
1772
|
+
return [...designArtifacts.values()]
|
|
1773
|
+
.filter((record) => record?.term === terminalId && typeof record.previewId === 'string')
|
|
1774
|
+
.slice(0, 16).map((record) => ({ type: 'preview', id: record.previewId }))
|
|
1775
|
+
}
|
|
1776
|
+
function clearScheduledRunDeadline(entry) {
|
|
1777
|
+
if (entry?.scheduleDeadlineTimer) clearTimeout(entry.scheduleDeadlineTimer)
|
|
1778
|
+
if (entry) entry.scheduleDeadlineTimer = null
|
|
1779
|
+
}
|
|
1780
|
+
const scheduledOutcomeWrites = new Set()
|
|
1781
|
+
async function releaseScheduledAdmissionForEntry(entry) {
|
|
1782
|
+
const lease = entry?.scheduleAdmissionLease
|
|
1783
|
+
if (!lease) return true
|
|
1784
|
+
const released = await releaseScheduledRunAdmission(lease)
|
|
1785
|
+
if (!released.ok) return false
|
|
1786
|
+
entry.scheduleAdmissionLease = null
|
|
1787
|
+
return true
|
|
1788
|
+
}
|
|
1789
|
+
async function persistScheduledRunOutcome(entry) {
|
|
1790
|
+
const intent = sanitizeScheduledOutcomeIntent(entry?.scheduleOutcomePending)
|
|
1791
|
+
if (!entry?.id || !intent || entry.scheduleOutcomeRecorded) return false
|
|
1792
|
+
const key = `${entry.id}:${intent.runId}`
|
|
1793
|
+
if (scheduledOutcomeWrites.has(key)) return false
|
|
1794
|
+
scheduledOutcomeWrites.add(key)
|
|
1795
|
+
try {
|
|
1796
|
+
const written = await finishScheduledRunWithRetry({
|
|
1797
|
+
supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, roomCode: room,
|
|
1798
|
+
runId: intent.runId, bridgeId: ACCOUNT_BRIDGE_ID, bridgeFencing: ACCOUNT_BRIDGE_FENCING,
|
|
1799
|
+
result: intent.result, canceled: intent.canceled, evidenceRefs: intent.evidenceRefs,
|
|
1800
|
+
})
|
|
1801
|
+
if (!written.ok) return false
|
|
1802
|
+
// The durable occurrence is now terminal, so it no longer consumes the
|
|
1803
|
+
// host-wide scheduled-run capacity. A failed local release remains leased
|
|
1804
|
+
// until its exact deadline and is retried from the restored session.
|
|
1805
|
+
await releaseScheduledAdmissionForEntry(entry)
|
|
1806
|
+
const liveEntry = sessions.get(entry.id) === entry
|
|
1807
|
+
if (!liveEntry) {
|
|
1808
|
+
// Closed terminals have no live snapshot to preserve. Their independent
|
|
1809
|
+
// outbox is the only local replay boundary. Tombstone the acknowledged
|
|
1810
|
+
// exact run before deletion so a failed delete can never re-finish it.
|
|
1811
|
+
if (!acknowledgePendingScheduledOutcome(room, entry.id, intent.runId)) return false
|
|
1812
|
+
return deletePendingScheduledOutcome(room, entry.id)
|
|
1813
|
+
}
|
|
1814
|
+
const priorPending = entry.scheduleOutcomePending
|
|
1815
|
+
entry.scheduleOutcomeRecorded = true
|
|
1816
|
+
entry.scheduleOutcomePending = null
|
|
1817
|
+
const committed = commitRecordedScheduledOutcome(room, entry.id, () => entry.flush?.())
|
|
1818
|
+
if (!committed.snapshotRecorded) {
|
|
1819
|
+
// The remote finish is idempotent. Retain the durable outbox and restore
|
|
1820
|
+
// in-memory pending state so reconciliation retries the whole commit.
|
|
1821
|
+
entry.scheduleOutcomeRecorded = false
|
|
1822
|
+
entry.scheduleOutcomePending = priorPending
|
|
1823
|
+
return false
|
|
1824
|
+
}
|
|
1825
|
+
// If deleting the outbox failed, keep the durably recorded live state. The
|
|
1826
|
+
// next reconciliation pass recognizes this exact run and retries deletion
|
|
1827
|
+
// without issuing another finish RPC.
|
|
1828
|
+
return committed.ok
|
|
1829
|
+
} finally {
|
|
1830
|
+
scheduledOutcomeWrites.delete(key)
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
function recordScheduledRunOutcome(entry, result, { canceled = false } = {}) {
|
|
1834
|
+
if (!entry?.scheduleRunId || entry.scheduleOutcomeRecorded || entry.scheduleOutcomePending) return false
|
|
1835
|
+
const intent = sanitizeScheduledOutcomeIntent({
|
|
1836
|
+
runId: entry.scheduleRunId,
|
|
1837
|
+
result,
|
|
1838
|
+
canceled,
|
|
1839
|
+
evidenceRefs: scheduledEvidenceForTerm(entry.id),
|
|
1840
|
+
})
|
|
1841
|
+
if (!intent) return false
|
|
1842
|
+
if (!savePendingScheduledOutcome(room, entry.id, intent)) return false
|
|
1843
|
+
entry.scheduleOutcomePending = intent
|
|
1844
|
+
clearScheduledRunDeadline(entry)
|
|
1845
|
+
entry.flush?.()
|
|
1846
|
+
void persistScheduledRunOutcome(entry)
|
|
1847
|
+
return true
|
|
1848
|
+
}
|
|
1849
|
+
async function recoverPendingScheduledRunOutcomes() {
|
|
1850
|
+
if (!codeAuthToken || !ACCOUNT_BRIDGE_ID || !Number.isSafeInteger(ACCOUNT_BRIDGE_FENCING) || ACCOUNT_BRIDGE_FENCING < 1) return false
|
|
1851
|
+
const savedRecords = new Map(loadAll(room).map((record) => [record?.id, record]).filter(([id]) => id))
|
|
1852
|
+
for (const { terminalId, intent: stored, acknowledged } of loadPendingScheduledOutcomes(room)) {
|
|
1853
|
+
// A durable acknowledgement tombstone is delete-only. It remains
|
|
1854
|
+
// authoritative even after the terminal snapshot has been archived.
|
|
1855
|
+
if (acknowledged) {
|
|
1856
|
+
deletePendingScheduledOutcome(room, terminalId)
|
|
1857
|
+
continue
|
|
1858
|
+
}
|
|
1859
|
+
const intent = sanitizeScheduledOutcomeIntent(stored)
|
|
1860
|
+
if (!intent) { deletePendingScheduledOutcome(room, terminalId); continue }
|
|
1861
|
+
let entry = sessions.get(terminalId)
|
|
1862
|
+
const saved = savedRecords.get(terminalId)
|
|
1863
|
+
// An acknowledged finish can leave its exact outbox behind if local
|
|
1864
|
+
// deletion failed after the recorded snapshot commit. Clean only that
|
|
1865
|
+
// exact run; never re-finish or reinterpret it.
|
|
1866
|
+
const recordedRunId = entry?.scheduleOutcomeRecorded === true ? entry.scheduleRunId
|
|
1867
|
+
: saved?.scheduleOutcomeRecorded === true ? saved.scheduleRunId
|
|
1868
|
+
: null
|
|
1869
|
+
if (recordedRunId === intent.runId) {
|
|
1870
|
+
deletePendingScheduledOutcome(room, terminalId)
|
|
1871
|
+
continue
|
|
1872
|
+
}
|
|
1873
|
+
// A live snapshot that has not restored yet is not a closed terminal. Wait
|
|
1874
|
+
// for the restore loop instead of racing it with a synthetic holder.
|
|
1875
|
+
if (!entry && saved) continue
|
|
1876
|
+
if (entry && entry.scheduleRunId && entry.scheduleRunId !== intent.runId) continue
|
|
1877
|
+
if (!entry) entry = { id: terminalId, scheduleRunId: intent.runId, scheduleOutcomeRecorded: false }
|
|
1878
|
+
entry.scheduleOutcomePending = intent
|
|
1879
|
+
await persistScheduledRunOutcome(entry)
|
|
1880
|
+
}
|
|
1881
|
+
return scheduledOutcomeReconciliationComplete(loadPendingScheduledOutcomes(room))
|
|
1882
|
+
}
|
|
1883
|
+
function armScheduledRunDeadline(entry) {
|
|
1884
|
+
clearScheduledRunDeadline(entry)
|
|
1885
|
+
if (!scheduledRunDeadlineRequired({
|
|
1886
|
+
runId: entry?.scheduleRunId,
|
|
1887
|
+
deadlineAt: entry?.scheduleDeadlineAt,
|
|
1888
|
+
outcomeRecorded: entry?.scheduleOutcomeRecorded,
|
|
1889
|
+
outcomePending: entry?.scheduleOutcomePending,
|
|
1890
|
+
})) return
|
|
1891
|
+
const remaining = Math.max(0, entry.scheduleDeadlineAt - Date.now())
|
|
1892
|
+
entry.scheduleDeadlineTimer = setTimeout(async () => {
|
|
1893
|
+
entry.scheduleDeadlineTimer = null
|
|
1894
|
+
if (entry.scheduleOutcomeRecorded) return
|
|
1895
|
+
entry.scheduleTimedOut = true
|
|
1896
|
+
// Settle every local permission promise before aborting the runtime. Otherwise
|
|
1897
|
+
// a permission card can keep the deadline path hung or later win classification.
|
|
1898
|
+
drainPending(entry)
|
|
1899
|
+
await queueAbortBarrier(entry)
|
|
1900
|
+
const evt = { kind: 'error', subtype: 'runtime_limit', message: 'Scheduled run reached its configured runtime ceiling.' }
|
|
1901
|
+
pushLog(entry, evt)
|
|
1902
|
+
bcast('code-event', { term: entry.id, evt })
|
|
1903
|
+
recordScheduledRunOutcome(entry, evt)
|
|
1904
|
+
entry.flush?.()
|
|
1905
|
+
announce()
|
|
1906
|
+
}, remaining)
|
|
1907
|
+
entry.scheduleDeadlineTimer.unref?.()
|
|
1908
|
+
}
|
|
1758
1909
|
// Realtime can carry the settled DOM for ordinary authored mockups. Including
|
|
1759
1910
|
// that already-uploaded snapshot in the correlated verified-live event lets an
|
|
1760
1911
|
// open Design lane advance revisions without a second auth/sign/download round
|
|
@@ -1893,6 +2044,20 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
|
|
|
1893
2044
|
const displaySource = designRecord
|
|
1894
2045
|
? resolveManifestDisplaySource(m, designRecord, { box })
|
|
1895
2046
|
: null
|
|
2047
|
+
const activeDesignRequest = designRecord ? designActive.get(term) : null
|
|
2048
|
+
const correlatedDesignRequest = !!(activeDesignRequest
|
|
2049
|
+
&& m.designRequestId === activeDesignRequest.request.cid
|
|
2050
|
+
&& m.parentRevision === activeDesignRequest.record.revision)
|
|
2051
|
+
const expectedDesignRevision = activeDesignRequest?.restoreRecord?.revision
|
|
2052
|
+
const designRevisionProvesChange = expectedDesignRevision
|
|
2053
|
+
? designRecord?.revision === expectedDesignRevision
|
|
2054
|
+
: designRecord?.revision !== activeDesignRequest?.record?.revision
|
|
2055
|
+
const sameDesignTarget = activeDesignRequest?.record?.sourceKind === 'preview'
|
|
2056
|
+
? designRecord?.sourceKind === 'preview' && designRecord?.captureKey === activeDesignRequest.record.captureKey
|
|
2057
|
+
: designRecord?.sourcePath === activeDesignRequest?.record?.sourcePath
|
|
2058
|
+
const verifiedParentRevision = correlatedDesignRequest && sameDesignTarget && designRevisionProvesChange
|
|
2059
|
+
? activeDesignRequest.record.revision
|
|
2060
|
+
: null
|
|
1896
2061
|
// 2026-07-07: these used to swallow read errors silently — a transient
|
|
1897
2062
|
// unreadable file (race with the render script, permissions, mid-write)
|
|
1898
2063
|
// meant the manifest still POSTed with that field missing, and nothing
|
|
@@ -1929,6 +2094,7 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
|
|
|
1929
2094
|
code: room, slug: m.slug, title: m.title, cid, ts: deliveryTs, term,
|
|
1930
2095
|
desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: designRecord ? displaySource : readTxt(m.html),
|
|
1931
2096
|
...(designRecord ? { previewId: designRecord.previewId, revision: designRecord.revision, sourceKnown: true } : {}),
|
|
2097
|
+
...(verifiedParentRevision ? { verified: true, parentRevision: verifiedParentRevision } : {}),
|
|
1932
2098
|
}),
|
|
1933
2099
|
})
|
|
1934
2100
|
if (!res.ok) {
|
|
@@ -1938,8 +2104,17 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
|
|
|
1938
2104
|
const uploaded = await res.json()
|
|
1939
2105
|
const { paths } = uploaded
|
|
1940
2106
|
const designAccepted = !!(designRecord && uploaded?.payload?.sourceKnown)
|
|
2107
|
+
const verifiedDesignRevision = !!(designAccepted
|
|
2108
|
+
&& verifiedParentRevision
|
|
2109
|
+
&& uploaded?.payload?.verified === true
|
|
2110
|
+
&& uploaded.payload.parentRevision === verifiedParentRevision)
|
|
1941
2111
|
const artifact = { kind: 'mockup', __struct: true, cid, term, slug: m.slug, title: m.title || m.slug, ts: deliveryTs, paths,
|
|
1942
|
-
...(designAccepted ? {
|
|
2112
|
+
...(designAccepted ? {
|
|
2113
|
+
previewId: designRecord.previewId,
|
|
2114
|
+
revision: designRecord.revision,
|
|
2115
|
+
sourceKnown: true,
|
|
2116
|
+
...(verifiedDesignRevision ? { verified: true, parentRevision: verifiedParentRevision } : {}),
|
|
2117
|
+
} : {}) }
|
|
1943
2118
|
if (designAccepted) {
|
|
1944
2119
|
Object.assign(designRecord, { term, slug: m.slug, title: m.title || m.slug, desktop: m.desktop, mobile: m.mobile })
|
|
1945
2120
|
try {
|
|
@@ -1949,15 +2124,10 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
|
|
|
1949
2124
|
if (!fs.existsSync(designRecord.backupPath)) fs.writeFileSync(designRecord.backupPath, designRecord.source)
|
|
1950
2125
|
} catch { /* restore stays unavailable if the host backup cannot be written */ }
|
|
1951
2126
|
designArtifacts.set(designRecord.previewId, designRecord)
|
|
1952
|
-
const active =
|
|
1953
|
-
const correlated =
|
|
2127
|
+
const active = activeDesignRequest
|
|
2128
|
+
const correlated = verifiedDesignRevision
|
|
1954
2129
|
const dual = !!(paths?.html && paths?.desktop && paths?.mobile && m.desktop && m.mobile)
|
|
1955
|
-
|
|
1956
|
-
const revisionProvesChange = expectedRevision ? designRecord.revision === expectedRevision : designRecord.revision !== active?.record?.revision
|
|
1957
|
-
const sameTarget = active?.record?.sourceKind === 'preview'
|
|
1958
|
-
? designRecord.sourceKind === 'preview' && designRecord.captureKey === active.record.captureKey
|
|
1959
|
-
: designRecord.sourcePath === active?.record?.sourcePath
|
|
1960
|
-
if (correlated && dual && sameTarget && revisionProvesChange) {
|
|
2130
|
+
if (correlated && dual) {
|
|
1961
2131
|
if (active.record.sourceKind !== 'preview') designRestore.set(designRecord.previewId, { priorRecord: active.record, request: active.request })
|
|
1962
2132
|
const artifactHtml = Buffer.byteLength(displaySource, 'utf8') <= MAX_INLINE_DESIGN_REVISION_BYTES
|
|
1963
2133
|
? displaySource
|
|
@@ -2353,7 +2523,7 @@ function worktreeSnapshot(cwd) {
|
|
|
2353
2523
|
// relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
|
|
2354
2524
|
// persist to the host file; tool calls round-trip through the perm card; the
|
|
2355
2525
|
// rolling log replays to joiners and survives bridge restarts (session-store).
|
|
2356
|
-
function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage, receivedTurnCids }) {
|
|
2526
|
+
function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage, receivedTurnCids, scheduleRunId, scheduleDeadlineAt, scheduleOutcomeRecorded, scheduleAdmissionLease }) {
|
|
2357
2527
|
if (sessions.has(id)) return
|
|
2358
2528
|
runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
|
|
2359
2529
|
// Fail closed before exposing a native lane if its bridge semantic contract
|
|
@@ -2400,6 +2570,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2400
2570
|
const restoredRawLog = Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : []
|
|
2401
2571
|
const restoredLog = boundStructuredLog(restoredRawLog)
|
|
2402
2572
|
const restoredOffset = restoredRawLog.length - restoredLog.length
|
|
2573
|
+
const restoredScheduleOutcomeCandidate = scheduleRunId && scheduleOutcomeRecorded !== true
|
|
2574
|
+
? sanitizeScheduledOutcomeIntent(loadPendingScheduledOutcome(room, id))
|
|
2575
|
+
: null
|
|
2576
|
+
const restoredScheduleOutcome = restoredScheduleOutcomeCandidate?.runId === scheduleRunId
|
|
2577
|
+
? restoredScheduleOutcomeCandidate
|
|
2578
|
+
: null
|
|
2403
2579
|
const entry = { cmd: runtime, runtime, kind: 'structured', log: restoredLog, pending: new Map(), receivedTurnCids: new Set(Array.isArray(receivedTurnCids) ? receivedTurnCids.filter(Boolean).slice(-1000) : []), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' ? (Array.isArray(models) && models.length ? models : hostHermesModels) : undefined,
|
|
2404
2580
|
// model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
|
|
2405
2581
|
// chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
|
|
@@ -2413,7 +2589,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2413
2589
|
: (!provider || provider === 'anthropic')
|
|
2414
2590
|
? (laneModel || null)
|
|
2415
2591
|
: (laneModel || providerNameMap()[provider] || provider),
|
|
2416
|
-
provider: provider || null, spawnedBy: spawnedBy || undefined, spawnDepth: structuralDepth, cascadeRole: cascadeRole === 'conductor' || cascadeRole === 'worker' ? cascadeRole : null, hop: initialHop, sideParent: sideParent || undefined, sideTask: sideTask || undefined, pendingSideContexts: Array.isArray(pendingSideContexts) ? pendingSideContexts.filter(Boolean).slice(-4) : [], pendingWorkerCompletions: Array.isArray(pendingWorkerCompletions) ? pendingWorkerCompletions.filter((item) => item?.workerId && Number(item?.turnRev) > 0).slice(-6) : [], workerCompletionsInFlight: Array.isArray(workerCompletionsInFlight) ? workerCompletionsInFlight.filter((item) => item?.workerId && Number(item?.turnRev) > 0).slice(-6) : [], flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null, managedWorktree: managedWorktree || null,
|
|
2592
|
+
provider: provider || null, spawnedBy: spawnedBy || undefined, spawnDepth: structuralDepth, cascadeRole: cascadeRole === 'conductor' || cascadeRole === 'worker' ? cascadeRole : null, hop: initialHop, sideParent: sideParent || undefined, sideTask: sideTask || undefined, scheduleRunId: scheduleRunId || null, scheduleDeadlineAt: Number.isFinite(Number(scheduleDeadlineAt)) ? Number(scheduleDeadlineAt) : null, scheduleOutcomeRecorded: scheduleOutcomeRecorded === true, scheduleOutcomePending: restoredScheduleOutcome, scheduleAdmissionLease: scheduleAdmissionLease || null, pendingSideContexts: Array.isArray(pendingSideContexts) ? pendingSideContexts.filter(Boolean).slice(-4) : [], pendingWorkerCompletions: Array.isArray(pendingWorkerCompletions) ? pendingWorkerCompletions.filter((item) => item?.workerId && Number(item?.turnRev) > 0).slice(-6) : [], workerCompletionsInFlight: Array.isArray(workerCompletionsInFlight) ? workerCompletionsInFlight.filter((item) => item?.workerId && Number(item?.turnRev) > 0).slice(-6) : [], flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null, managedWorktree: managedWorktree || null,
|
|
2417
2593
|
sliceType: sliceType === 'review' ? 'review' : null,
|
|
2418
2594
|
flowRole: flowRole || (flowSessionId ? (flowTaskKey ? ((flowReviewTargets?.length || reviewSliceRoots?.length) ? 'reviewer' : 'builder') : 'conductor') : null),
|
|
2419
2595
|
flowReviewTarget: flowReviewTarget || null,
|
|
@@ -2650,6 +2826,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2650
2826
|
// with no archive yet; pushLog stamps it from the first appended event.
|
|
2651
2827
|
entry.archiveOldestSeq = readDurableOldestSeq(room, id)
|
|
2652
2828
|
sessions.set(id, entry)
|
|
2829
|
+
// Bind and replay an already-chosen durable outcome before any elapsed
|
|
2830
|
+
// restored deadline can arm and overwrite it.
|
|
2831
|
+
if (entry.scheduleOutcomePending) void persistScheduledRunOutcome(entry)
|
|
2832
|
+
else armScheduledRunDeadline(entry)
|
|
2653
2833
|
// Close an interrupted turn so a restored terminal comes back IDLE-with-history
|
|
2654
2834
|
// instead of stuck "thinking" (see restoredTurnOpen). Idempotent: the closer is
|
|
2655
2835
|
// persisted, so a later resume sees a closed turn and no-ops.
|
|
@@ -2670,7 +2850,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2670
2850
|
// Visual QA runs in the bridge process, outside either agent runtime's sandbox.
|
|
2671
2851
|
// It remains scoped to this lane's cwd and private mockup outbox.
|
|
2672
2852
|
entry.viewport = new ViewportManager({
|
|
2673
|
-
workspaceRoot: cwd || process.cwd(), ownerId: id, outbox: mockupOutbox,
|
|
2853
|
+
workspaceRoot: cwd || process.cwd(), ownerId: id, roomCode: room, outbox: mockupOutbox,
|
|
2674
2854
|
authContext: () => ({ accessToken: codeAuthToken, supabaseUrl: SUPABASE_URL }),
|
|
2675
2855
|
designContext: () => {
|
|
2676
2856
|
const active = designActive.get(id)
|
|
@@ -2690,13 +2870,18 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2690
2870
|
// restart. Without this, sessionData omitted it → on restart the resumed session
|
|
2691
2871
|
// re-launched on the host default (Opus) regardless of the last switch, and the
|
|
2692
2872
|
// switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
|
|
2693
|
-
const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, pendingWorkerCompletions: entry.pendingWorkerCompletions, workerCompletionsInFlight: entry.workerCompletionsInFlight, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewSnapshots: entry.flowReviewSnapshots || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null, receivedTurnCids: [...entry.receivedTurnCids].slice(-1000) })
|
|
2873
|
+
const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, scheduleRunId: entry.scheduleRunId || null, scheduleDeadlineAt: entry.scheduleDeadlineAt || null, scheduleOutcomeRecorded: entry.scheduleOutcomeRecorded === true, scheduleAdmissionLease: entry.scheduleAdmissionLease || null, pendingSideContexts: entry.pendingSideContexts, pendingWorkerCompletions: entry.pendingWorkerCompletions, workerCompletionsInFlight: entry.workerCompletionsInFlight, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewSnapshots: entry.flowReviewSnapshots || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null, receivedTurnCids: [...entry.receivedTurnCids].slice(-1000) })
|
|
2694
2874
|
const persist = () => saveSession(room, id, sessionData())
|
|
2695
2875
|
// Synchronous flush of this session's record. Used on open (so a brand-new session
|
|
2696
2876
|
// has a file under its id BEFORE its first event — surviving a restart inside the
|
|
2697
2877
|
// 1.5s saveSession debounce window) and on shutdown (so events since the last
|
|
2698
2878
|
// debounced write aren't lost). Contract #2: restart resumes, no lost messages.
|
|
2699
2879
|
entry.flush = () => flushSession(room, id, sessionData())
|
|
2880
|
+
if (entry.scheduleOutcomeRecorded && entry.scheduleAdmissionLease) {
|
|
2881
|
+
void releaseScheduledAdmissionForEntry(entry).then((released) => {
|
|
2882
|
+
if (released) entry.flush?.()
|
|
2883
|
+
})
|
|
2884
|
+
}
|
|
2700
2885
|
// Main-terminal creation and Ensemble dispatch are different capabilities.
|
|
2701
2886
|
// A main terminal (human-opened or agent-opened via open_main_terminal) may
|
|
2702
2887
|
// dispatch workers. A spawned/Side/Flow lane can never elevate itself or a
|
|
@@ -3869,6 +4054,14 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3869
4054
|
if (mockupDeliveryBoundary) {
|
|
3870
4055
|
queueMicrotask(() => { mockupDeliveries.flush(id).catch(() => {}) })
|
|
3871
4056
|
}
|
|
4057
|
+
// A scheduled run has one durable terminal outcome. Deliberately do this
|
|
4058
|
+
// after its terminal boundary is emitted, so a preview created by that turn
|
|
4059
|
+
// is visible before it can count as a deliverable. Plain prose success is
|
|
4060
|
+
// classified as missing_deliverable by the pure runner.
|
|
4061
|
+
if (terminalBoundary && entry.scheduleRunId && (e.kind === 'result' || e.kind === 'error')) {
|
|
4062
|
+
const scheduledResult = entry.scheduleTimedOut ? { ...e, subtype: 'runtime_limit' } : e
|
|
4063
|
+
recordScheduledRunOutcome(entry, scheduledResult, { canceled: !entry.scheduleTimedOut && e.subtype === 'aborted' })
|
|
4064
|
+
}
|
|
3872
4065
|
}
|
|
3873
4066
|
// A tool_result carrying an inline base64 image can't ride a broadcast frame
|
|
3874
4067
|
// (live OR replay). Lift it to Storage FIRST, then emit the URL-only event —
|
|
@@ -4105,6 +4298,8 @@ function endStructured(id) {
|
|
|
4105
4298
|
if (!id) return
|
|
4106
4299
|
const s = sessions.get(id)
|
|
4107
4300
|
if (s) {
|
|
4301
|
+
recordScheduledRunOutcome(s, { subtype: 'canceled' }, { canceled: true })
|
|
4302
|
+
clearScheduledRunDeadline(s)
|
|
4108
4303
|
s.imageQueue?.close()
|
|
4109
4304
|
mockupDeliveries.clear(id)
|
|
4110
4305
|
drainPending(s)
|
|
@@ -5041,7 +5236,7 @@ channel
|
|
|
5041
5236
|
// FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
|
|
5042
5237
|
// bridge restart: the conductor keeps its subagent-block + plan interception, and
|
|
5043
5238
|
// lanes keep their worktree cwd + the ability to mark done.
|
|
5044
|
-
openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, pendingSideContexts: rec.pendingSideContexts, pendingWorkerCompletions: rec.pendingWorkerCompletions, workerCompletionsInFlight: rec.workerCompletionsInFlight, sliceType: rec.sliceType, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewSnapshots: rec.flowReviewSnapshots, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, receivedTurnCids: rec.receivedTurnCids, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
|
|
5239
|
+
openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, scheduleRunId: rec.scheduleRunId, scheduleDeadlineAt: rec.scheduleDeadlineAt, scheduleOutcomeRecorded: rec.scheduleOutcomeRecorded, scheduleAdmissionLease: rec.scheduleAdmissionLease, pendingSideContexts: rec.pendingSideContexts, pendingWorkerCompletions: rec.pendingWorkerCompletions, workerCompletionsInFlight: rec.workerCompletionsInFlight, sliceType: rec.sliceType, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewSnapshots: rec.flowReviewSnapshots, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, receivedTurnCids: rec.receivedTurnCids, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
|
|
5045
5240
|
// Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
|
|
5046
5241
|
// shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
|
|
5047
5242
|
// (mid-turn needs auto-resume; flow needs its lane live).
|
|
@@ -5518,6 +5713,83 @@ designChannel
|
|
|
5518
5713
|
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') process.stderr.write(`\n ⚠ design realtime ${status} (tpdesign:${room}).\n`)
|
|
5519
5714
|
})
|
|
5520
5715
|
|
|
5716
|
+
// Owner-bridge-only, local polling for V1 unattended schedules. The database claim
|
|
5717
|
+
// is the cross-process fence; this guard only prevents overlapping HTTP ticks in one
|
|
5718
|
+
// bridge. A due run always becomes an ordinary top-level terminal with run provenance.
|
|
5719
|
+
let scheduledRunPollBusy = false
|
|
5720
|
+
const scheduledProviderAvailable = (schedule) => {
|
|
5721
|
+
const runtime = schedule?.runtime
|
|
5722
|
+
const provider = schedule?.provider_id || 'anthropic'
|
|
5723
|
+
if (runtime === 'claude') {
|
|
5724
|
+
if (!listProviders().some((item) => item.id === provider)) return false
|
|
5725
|
+
return provider === 'anthropic' || !schedule?.model || providerModel(provider) === schedule.model
|
|
5726
|
+
}
|
|
5727
|
+
if (schedule?.provider_id) return false // V1 never silently maps non-Claude providers.
|
|
5728
|
+
if (runtime === 'codex') return !schedule?.model || modelCatalogValues(readCodexModels()).has(schedule.model)
|
|
5729
|
+
if (runtime === 'hermes') return !schedule?.model || hostHermesModels.some((item) => (item?.id || item?.model) === schedule.model)
|
|
5730
|
+
return false
|
|
5731
|
+
}
|
|
5732
|
+
const pollScheduledRuns = async () => {
|
|
5733
|
+
if (scheduledRunPollBusy || shuttingDown || !codeAuthToken || !myServeUid || !ACCOUNT_BRIDGE_ID || !Number.isSafeInteger(ACCOUNT_BRIDGE_FENCING) || ACCOUNT_BRIDGE_FENCING < 1) return
|
|
5734
|
+
scheduledRunPollBusy = true
|
|
5735
|
+
try {
|
|
5736
|
+
// Finishing a previously-opened occurrence is cleanup, not a new scheduled
|
|
5737
|
+
// execution. Recover it even when the owner has since disabled new runs.
|
|
5738
|
+
const outcomesReconciled = await recoverPendingScheduledRunOutcomes()
|
|
5739
|
+
// expire_code_scheduled_runs is intentionally downstream of host-outbox
|
|
5740
|
+
// reconciliation. Otherwise a restart can expire a delivered/decision run
|
|
5741
|
+
// while its idempotent finish is still waiting for a database ack.
|
|
5742
|
+
if (!outcomesReconciled) return
|
|
5743
|
+
if (!scheduledRunsEnabled()) return
|
|
5744
|
+
const result = await pollDueScheduledRuns({
|
|
5745
|
+
enabled: true, supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, roomCode: room, bridgeId: ACCOUNT_BRIDGE_ID, bridgeFencing: ACCOUNT_BRIDGE_FENCING, ownerId: myServeUid,
|
|
5746
|
+
providerAvailable: scheduledProviderAvailable,
|
|
5747
|
+
admitRun: async ({ runId, terminalId, deadlineAt }) => {
|
|
5748
|
+
const localAdmission = scheduledRunSpawnAdmission({
|
|
5749
|
+
// The rolling burst authority is the host-wide ledger below. This
|
|
5750
|
+
// local gate retains the room process kill-switch and live-lane cap.
|
|
5751
|
+
spawnTimes: [],
|
|
5752
|
+
totalLive: sessions.size + terms.size,
|
|
5753
|
+
disabled: process.env.TP_SPAWN_OFF === '1',
|
|
5754
|
+
})
|
|
5755
|
+
if (!localAdmission.ok) return localAdmission
|
|
5756
|
+
return acquireScheduledRunAdmission({ runId, terminalId, deadlineAt })
|
|
5757
|
+
},
|
|
5758
|
+
releaseRun: (lease) => releaseScheduledRunAdmission(lease),
|
|
5759
|
+
openTerminal: async ({ schedule, run, terminalId: id, admission }) => {
|
|
5760
|
+
// The durable occurrence is opened before this local terminal. Never reuse
|
|
5761
|
+
// an existing session or worker lane for unattended work.
|
|
5762
|
+
const leaseExpiry = Date.parse(run.lease_expires_at)
|
|
5763
|
+
const scheduleDeadlineAt = Number.isFinite(leaseExpiry) ? leaseExpiry - 60_000 : NaN
|
|
5764
|
+
if (!Number.isFinite(scheduleDeadlineAt) || scheduleDeadlineAt <= Date.now()) return null
|
|
5765
|
+
const entry = openStructured({ id, runtime: schedule.runtime, provider: schedule.provider_id || undefined, model: schedule.model || undefined, mode: 'default', scheduleRunId: run.id, scheduleDeadlineAt, scheduleAdmissionLease: admission })
|
|
5766
|
+
if (!entry) return null
|
|
5767
|
+
termNames[id] = String(schedule.title || 'Scheduled run').slice(0, 80)
|
|
5768
|
+
saveNames(room, termNames)
|
|
5769
|
+
const accepted = dispatchStructuredTurn(entry, String(schedule.prompt || ''), {
|
|
5770
|
+
visibleEvent: { kind: 'you', text: String(schedule.prompt || ''), by: 'Scheduled run' },
|
|
5771
|
+
rejectionMessage: 'The scheduled run could not start; no hidden fallback was used.',
|
|
5772
|
+
})
|
|
5773
|
+
if (!accepted?.accepted) {
|
|
5774
|
+
// pollDueScheduledRuns owns the explicit terminal_open_failed outcome.
|
|
5775
|
+
// Detach this process-local entry before closing so endStructured cannot
|
|
5776
|
+
// race it with a synthetic canceled outcome.
|
|
5777
|
+
entry.scheduleRunId = null
|
|
5778
|
+
clearScheduledRunDeadline(entry)
|
|
5779
|
+
endStructured(id)
|
|
5780
|
+
return null
|
|
5781
|
+
}
|
|
5782
|
+
armScheduledRunDeadline(entry)
|
|
5783
|
+
entry.flush?.(); announce()
|
|
5784
|
+
return id
|
|
5785
|
+
},
|
|
5786
|
+
})
|
|
5787
|
+
if (!result.ok && process.env.TP_DEBUG && result.code !== 'runner_read_failed') process.stderr.write(`\n ◇ scheduled-run poll: ${result.code}\n`)
|
|
5788
|
+
} finally { scheduledRunPollBusy = false }
|
|
5789
|
+
}
|
|
5790
|
+
void pollScheduledRuns()
|
|
5791
|
+
setInterval(() => { void pollScheduledRuns() }, 15_000).unref()
|
|
5792
|
+
|
|
5521
5793
|
// Watchdog — restart only for a Realtime-only wedge. A total network/Supabase
|
|
5522
5794
|
// outage cannot be repaired by killing the room process; doing so destroys local
|
|
5523
5795
|
// agent runtimes and leaves the supervisor unable to rediscover the room until HTTP
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.360",
|
|
4
4
|
"description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -63,6 +63,8 @@
|
|
|
63
63
|
"evidence-citations.mjs",
|
|
64
64
|
"error-recovery.mjs",
|
|
65
65
|
"pair-control-authority.mjs",
|
|
66
|
+
"scheduled-runs.mjs",
|
|
67
|
+
"scheduled-run-admission.mjs",
|
|
66
68
|
"event-id.mjs",
|
|
67
69
|
"event-bounds.mjs",
|
|
68
70
|
"replay-transport.mjs",
|