thinkpool-pair 0.7.357 → 0.7.359
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 +394 -70
- package/codex-session.mjs +25 -5
- 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 +6 -4
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,149 @@ 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
|
+
}
|
|
1909
|
+
// Realtime can carry the settled DOM for ordinary authored mockups. Including
|
|
1910
|
+
// that already-uploaded snapshot in the correlated verified-live event lets an
|
|
1911
|
+
// open Design lane advance revisions without a second auth/sign/download round
|
|
1912
|
+
// trip. Large artifacts keep the normal signed-storage path.
|
|
1913
|
+
const MAX_INLINE_DESIGN_REVISION_BYTES = 128 * 1024
|
|
1758
1914
|
const designStatus = (payload) => designChannel.send({
|
|
1759
1915
|
type: 'broadcast', event: 'design-status', payload: { ts: Date.now(), ...payload },
|
|
1760
1916
|
})
|
|
@@ -1777,7 +1933,12 @@ const finishDesign = (term, state, extra = {}) => {
|
|
|
1777
1933
|
const maybeVerifyDesign = (term) => {
|
|
1778
1934
|
const active = designActive.get(term)
|
|
1779
1935
|
if (!active?.resultOk || !active.proof) return
|
|
1780
|
-
finishDesign(term, 'verified-live', {
|
|
1936
|
+
finishDesign(term, 'verified-live', {
|
|
1937
|
+
message: 'Verified at desktop and mobile.',
|
|
1938
|
+
artifact: active.proof.artifact,
|
|
1939
|
+
...(active.proof.artifactHtml ? { artifactHtml: active.proof.artifactHtml } : {}),
|
|
1940
|
+
canRestore: active.record.sourceKind !== 'preview',
|
|
1941
|
+
})
|
|
1781
1942
|
}
|
|
1782
1943
|
|
|
1783
1944
|
function pumpDesign(term) {
|
|
@@ -1883,6 +2044,20 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
|
|
|
1883
2044
|
const displaySource = designRecord
|
|
1884
2045
|
? resolveManifestDisplaySource(m, designRecord, { box })
|
|
1885
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
|
|
1886
2061
|
// 2026-07-07: these used to swallow read errors silently — a transient
|
|
1887
2062
|
// unreadable file (race with the render script, permissions, mid-write)
|
|
1888
2063
|
// meant the manifest still POSTed with that field missing, and nothing
|
|
@@ -1919,6 +2094,7 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
|
|
|
1919
2094
|
code: room, slug: m.slug, title: m.title, cid, ts: deliveryTs, term,
|
|
1920
2095
|
desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: designRecord ? displaySource : readTxt(m.html),
|
|
1921
2096
|
...(designRecord ? { previewId: designRecord.previewId, revision: designRecord.revision, sourceKnown: true } : {}),
|
|
2097
|
+
...(verifiedParentRevision ? { verified: true, parentRevision: verifiedParentRevision } : {}),
|
|
1922
2098
|
}),
|
|
1923
2099
|
})
|
|
1924
2100
|
if (!res.ok) {
|
|
@@ -1928,8 +2104,17 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
|
|
|
1928
2104
|
const uploaded = await res.json()
|
|
1929
2105
|
const { paths } = uploaded
|
|
1930
2106
|
const designAccepted = !!(designRecord && uploaded?.payload?.sourceKnown)
|
|
2107
|
+
const verifiedDesignRevision = !!(designAccepted
|
|
2108
|
+
&& verifiedParentRevision
|
|
2109
|
+
&& uploaded?.payload?.verified === true
|
|
2110
|
+
&& uploaded.payload.parentRevision === verifiedParentRevision)
|
|
1931
2111
|
const artifact = { kind: 'mockup', __struct: true, cid, term, slug: m.slug, title: m.title || m.slug, ts: deliveryTs, paths,
|
|
1932
|
-
...(designAccepted ? {
|
|
2112
|
+
...(designAccepted ? {
|
|
2113
|
+
previewId: designRecord.previewId,
|
|
2114
|
+
revision: designRecord.revision,
|
|
2115
|
+
sourceKnown: true,
|
|
2116
|
+
...(verifiedDesignRevision ? { verified: true, parentRevision: verifiedParentRevision } : {}),
|
|
2117
|
+
} : {}) }
|
|
1933
2118
|
if (designAccepted) {
|
|
1934
2119
|
Object.assign(designRecord, { term, slug: m.slug, title: m.title || m.slug, desktop: m.desktop, mobile: m.mobile })
|
|
1935
2120
|
try {
|
|
@@ -1939,17 +2124,15 @@ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m
|
|
|
1939
2124
|
if (!fs.existsSync(designRecord.backupPath)) fs.writeFileSync(designRecord.backupPath, designRecord.source)
|
|
1940
2125
|
} catch { /* restore stays unavailable if the host backup cannot be written */ }
|
|
1941
2126
|
designArtifacts.set(designRecord.previewId, designRecord)
|
|
1942
|
-
const active =
|
|
1943
|
-
const correlated =
|
|
2127
|
+
const active = activeDesignRequest
|
|
2128
|
+
const correlated = verifiedDesignRevision
|
|
1944
2129
|
const dual = !!(paths?.html && paths?.desktop && paths?.mobile && m.desktop && m.mobile)
|
|
1945
|
-
|
|
1946
|
-
const revisionProvesChange = expectedRevision ? designRecord.revision === expectedRevision : designRecord.revision !== active?.record?.revision
|
|
1947
|
-
const sameTarget = active?.record?.sourceKind === 'preview'
|
|
1948
|
-
? designRecord.sourceKind === 'preview' && designRecord.captureKey === active.record.captureKey
|
|
1949
|
-
: designRecord.sourcePath === active?.record?.sourcePath
|
|
1950
|
-
if (correlated && dual && sameTarget && revisionProvesChange) {
|
|
2130
|
+
if (correlated && dual) {
|
|
1951
2131
|
if (active.record.sourceKind !== 'preview') designRestore.set(designRecord.previewId, { priorRecord: active.record, request: active.request })
|
|
1952
|
-
|
|
2132
|
+
const artifactHtml = Buffer.byteLength(displaySource, 'utf8') <= MAX_INLINE_DESIGN_REVISION_BYTES
|
|
2133
|
+
? displaySource
|
|
2134
|
+
: null
|
|
2135
|
+
active.proof = { artifact, artifactHtml }
|
|
1953
2136
|
maybeVerifyDesign(term)
|
|
1954
2137
|
}
|
|
1955
2138
|
}
|
|
@@ -2238,6 +2421,80 @@ function restoredTurnOpen(log) {
|
|
|
2238
2421
|
return false
|
|
2239
2422
|
}
|
|
2240
2423
|
|
|
2424
|
+
function emitCodexCompactionControl(entry, text, by = null) {
|
|
2425
|
+
const evt = { kind: 'control', text, by }
|
|
2426
|
+
pushLog(entry, evt)
|
|
2427
|
+
bcast('code-event', { term: entry.id, evt })
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
async function runCodexCompaction(entry, request = {}) {
|
|
2431
|
+
const recap = buildRecapFromLog(entry.log, RECAP_CAP, { reason: 'compact' })
|
|
2432
|
+
if (!recap) {
|
|
2433
|
+
emitCodexCompactionControl(entry, 'nothing to compact — context unchanged', request.by)
|
|
2434
|
+
return
|
|
2435
|
+
}
|
|
2436
|
+
const preTokens = entry.lastUsage?.ctx?.used || null
|
|
2437
|
+
bcast('code-event', { term: entry.id, evt: { kind: 'compact', status: 'start', ts: Date.now() } })
|
|
2438
|
+
let nativeCompacted = false
|
|
2439
|
+
try {
|
|
2440
|
+
nativeCompacted = await entry.session.compactContext?.()
|
|
2441
|
+
} catch {
|
|
2442
|
+
// compactContext classifies delivery certainty. A truly unexpected throw
|
|
2443
|
+
// remains non-replayable here; resetting after an unknown native request can
|
|
2444
|
+
// execute compaction twice against two different threads.
|
|
2445
|
+
nativeCompacted = null
|
|
2446
|
+
} finally {
|
|
2447
|
+
// Native compaction emits usage while compactActive=true, so onEvent
|
|
2448
|
+
// legitimately announces a busy edge. It has no ordinary result event;
|
|
2449
|
+
// close that borrowed edge after compactContext clears compactActive.
|
|
2450
|
+
if (!entry.session.turnActive && settleLaneControl(entry)) announce()
|
|
2451
|
+
}
|
|
2452
|
+
if (nativeCompacted === true) {
|
|
2453
|
+
const evt = { kind: 'compaction', trigger: 'manual', preTokens, by: request.by, native: true }
|
|
2454
|
+
pushLog(entry, evt)
|
|
2455
|
+
bcast('code-event', { term: entry.id, evt })
|
|
2456
|
+
bcast('code-event', { term: entry.id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
|
|
2457
|
+
entry.flush?.()
|
|
2458
|
+
return
|
|
2459
|
+
}
|
|
2460
|
+
if (nativeCompacted == null) {
|
|
2461
|
+
bcast('code-event', { term: entry.id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
|
|
2462
|
+
emitCodexCompactionControl(entry, 'Native compaction is still settling; existing context was preserved.', request.by)
|
|
2463
|
+
entry.flush?.()
|
|
2464
|
+
return
|
|
2465
|
+
}
|
|
2466
|
+
if (entry.session.clearContext?.() === false) {
|
|
2467
|
+
bcast('code-event', { term: entry.id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
|
|
2468
|
+
emitCodexCompactionControl(entry, 'Codex context compaction unavailable right now', request.by)
|
|
2469
|
+
return
|
|
2470
|
+
}
|
|
2471
|
+
entry.pendingRecap = recap
|
|
2472
|
+
const evt = { kind: 'compaction', trigger: 'manual', preTokens, by: request.by }
|
|
2473
|
+
pushLog(entry, evt)
|
|
2474
|
+
bcast('code-event', { term: entry.id, evt })
|
|
2475
|
+
// The new native thread has only the bounded recap queued for the next human
|
|
2476
|
+
// turn. Reset the visible meter to that conservative text estimate.
|
|
2477
|
+
if (entry.lastUsage?.ctx?.max) {
|
|
2478
|
+
const used = Math.ceil(recap.length / 4)
|
|
2479
|
+
const max = entry.lastUsage.ctx.max
|
|
2480
|
+
const usage = { kind: 'usage', ctx: { ...entry.lastUsage.ctx, used, max, pct: Math.min(100, Math.round((used / max) * 100)), over: used > max } }
|
|
2481
|
+
entry.lastUsage = usage
|
|
2482
|
+
bcast('code-event', { term: entry.id, evt: usage })
|
|
2483
|
+
}
|
|
2484
|
+
bcast('code-event', { term: entry.id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
|
|
2485
|
+
entry.flush?.()
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
function startCodexCompaction(entry, request = {}) {
|
|
2489
|
+
if (!entry || entry.runtime !== 'codex' || entry.codexCompactionPromise) return false
|
|
2490
|
+
const job = runCodexCompaction(entry, request)
|
|
2491
|
+
entry.codexCompactionPromise = job
|
|
2492
|
+
void job.finally(() => {
|
|
2493
|
+
if (entry.codexCompactionPromise === job) entry.codexCompactionPromise = null
|
|
2494
|
+
}).catch(() => { /* runCodexCompaction already surfaced classified failures */ })
|
|
2495
|
+
return true
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2241
2498
|
// Active-worktree snapshot for the per-turn ROOM NOW tail. Cached ~60s per cwd —
|
|
2242
2499
|
// a subprocess on every turn of every lane would be waste, and worktrees change on
|
|
2243
2500
|
// minutes-scale. Plain `git worktree list` output (path · sha · [branch]) is the
|
|
@@ -2266,7 +2523,7 @@ function worktreeSnapshot(cwd) {
|
|
|
2266
2523
|
// relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
|
|
2267
2524
|
// persist to the host file; tool calls round-trip through the perm card; the
|
|
2268
2525
|
// rolling log replays to joiners and survives bridge restarts (session-store).
|
|
2269
|
-
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 }) {
|
|
2270
2527
|
if (sessions.has(id)) return
|
|
2271
2528
|
runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
|
|
2272
2529
|
// Fail closed before exposing a native lane if its bridge semantic contract
|
|
@@ -2313,6 +2570,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2313
2570
|
const restoredRawLog = Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : []
|
|
2314
2571
|
const restoredLog = boundStructuredLog(restoredRawLog)
|
|
2315
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
|
|
2316
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,
|
|
2317
2580
|
// model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
|
|
2318
2581
|
// chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
|
|
@@ -2326,7 +2589,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2326
2589
|
: (!provider || provider === 'anthropic')
|
|
2327
2590
|
? (laneModel || null)
|
|
2328
2591
|
: (laneModel || providerNameMap()[provider] || provider),
|
|
2329
|
-
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,
|
|
2330
2593
|
sliceType: sliceType === 'review' ? 'review' : null,
|
|
2331
2594
|
flowRole: flowRole || (flowSessionId ? (flowTaskKey ? ((flowReviewTargets?.length || reviewSliceRoots?.length) ? 'reviewer' : 'builder') : 'conductor') : null),
|
|
2332
2595
|
flowReviewTarget: flowReviewTarget || null,
|
|
@@ -2563,6 +2826,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2563
2826
|
// with no archive yet; pushLog stamps it from the first appended event.
|
|
2564
2827
|
entry.archiveOldestSeq = readDurableOldestSeq(room, id)
|
|
2565
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)
|
|
2566
2833
|
// Close an interrupted turn so a restored terminal comes back IDLE-with-history
|
|
2567
2834
|
// instead of stuck "thinking" (see restoredTurnOpen). Idempotent: the closer is
|
|
2568
2835
|
// persisted, so a later resume sees a closed turn and no-ops.
|
|
@@ -2603,13 +2870,18 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2603
2870
|
// restart. Without this, sessionData omitted it → on restart the resumed session
|
|
2604
2871
|
// re-launched on the host default (Opus) regardless of the last switch, and the
|
|
2605
2872
|
// switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
|
|
2606
|
-
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) })
|
|
2607
2874
|
const persist = () => saveSession(room, id, sessionData())
|
|
2608
2875
|
// Synchronous flush of this session's record. Used on open (so a brand-new session
|
|
2609
2876
|
// has a file under its id BEFORE its first event — surviving a restart inside the
|
|
2610
2877
|
// 1.5s saveSession debounce window) and on shutdown (so events since the last
|
|
2611
2878
|
// debounced write aren't lost). Contract #2: restart resumes, no lost messages.
|
|
2612
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
|
+
}
|
|
2613
2885
|
// Main-terminal creation and Ensemble dispatch are different capabilities.
|
|
2614
2886
|
// A main terminal (human-opened or agent-opened via open_main_terminal) may
|
|
2615
2887
|
// dispatch workers. A spawned/Side/Flow lane can never elevate itself or a
|
|
@@ -3656,6 +3928,15 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3656
3928
|
pushLog(entry, ce)
|
|
3657
3929
|
bcast('code-event', { term: id, evt: ce })
|
|
3658
3930
|
}
|
|
3931
|
+
// A busy Codex lane accepts one deferred /compact intent. Claim the
|
|
3932
|
+
// compaction synchronously at the terminal result boundary so a new human
|
|
3933
|
+
// turn cannot slip into the gap, and coalesce every repeated tap into this
|
|
3934
|
+
// one job.
|
|
3935
|
+
if ((evt.kind === 'result' || evt.kind === 'error') && entry.runtime === 'codex' && entry.pendingCodexCompaction) {
|
|
3936
|
+
const request = entry.pendingCodexCompaction
|
|
3937
|
+
entry.pendingCodexCompaction = null
|
|
3938
|
+
startCodexCompaction(entry, request)
|
|
3939
|
+
}
|
|
3659
3940
|
// ── Slice 3: the turn settled. Two things happen, in this order.
|
|
3660
3941
|
// 1. Every outstanding permission card on this lane is now dead (the SDK
|
|
3661
3942
|
// won't ask again for a turn that ended) — clearAll retracts any
|
|
@@ -3773,6 +4054,14 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3773
4054
|
if (mockupDeliveryBoundary) {
|
|
3774
4055
|
queueMicrotask(() => { mockupDeliveries.flush(id).catch(() => {}) })
|
|
3775
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
|
+
}
|
|
3776
4065
|
}
|
|
3777
4066
|
// A tool_result carrying an inline base64 image can't ride a broadcast frame
|
|
3778
4067
|
// (live OR replay). Lift it to Storage FIRST, then emit the URL-only event —
|
|
@@ -4009,6 +4298,8 @@ function endStructured(id) {
|
|
|
4009
4298
|
if (!id) return
|
|
4010
4299
|
const s = sessions.get(id)
|
|
4011
4300
|
if (s) {
|
|
4301
|
+
recordScheduledRunOutcome(s, { subtype: 'canceled' }, { canceled: true })
|
|
4302
|
+
clearScheduledRunDeadline(s)
|
|
4012
4303
|
s.imageQueue?.close()
|
|
4013
4304
|
mockupDeliveries.clear(id)
|
|
4014
4305
|
drainPending(s)
|
|
@@ -4678,6 +4969,7 @@ channel
|
|
|
4678
4969
|
}
|
|
4679
4970
|
if (/^\/clear\s*$/.test(text)) {
|
|
4680
4971
|
s.pendingRecap = null // /clear means forget context — drop any un-fired carry recap
|
|
4972
|
+
s.pendingCodexCompaction = null // explicit clear supersedes a deferred compact
|
|
4681
4973
|
if (s.runtime === 'codex' || s.runtime === 'hermes') s.session.clearContext?.()
|
|
4682
4974
|
else s.session.sendTurn(text)
|
|
4683
4975
|
// The seq reset is load-bearing on BOTH ends: the client's clear handler sets
|
|
@@ -4698,61 +4990,16 @@ channel
|
|
|
4698
4990
|
// No persisted ctl line — the live indicator + the CompactionCard are the record.
|
|
4699
4991
|
if (/^\/compact\s*$/.test(text)) {
|
|
4700
4992
|
if (s.runtime === 'codex') {
|
|
4993
|
+
// One lifecycle owns every distinct press. During an ordinary turn the
|
|
4994
|
+
// first request is queued and truthfully runs at its result boundary;
|
|
4995
|
+
// later taps, including taps during native compaction, coalesce silently.
|
|
4996
|
+
if (s.codexCompactionPromise || s.pendingCodexCompaction) return
|
|
4701
4997
|
if (s.session.turnActive) {
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
}
|
|
4705
|
-
const recap = buildRecapFromLog(s.log, RECAP_CAP, { reason: 'compact' })
|
|
4706
|
-
if (!recap) {
|
|
4707
|
-
ctlLine('nothing to compact — context unchanged')
|
|
4708
|
-
return
|
|
4709
|
-
}
|
|
4710
|
-
const preTokens = s.lastUsage?.ctx?.used || null
|
|
4711
|
-
bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'start', ts: Date.now() } })
|
|
4712
|
-
let nativeCompacted = false
|
|
4713
|
-
try {
|
|
4714
|
-
nativeCompacted = await s.session.compactContext?.()
|
|
4715
|
-
} catch {
|
|
4716
|
-
// The bounded recap below is the safe fallback for a native transport
|
|
4717
|
-
// failure. The roster edge is still settled in finally.
|
|
4718
|
-
} finally {
|
|
4719
|
-
// Native compaction emits usage while compactActive=true, so onEvent
|
|
4720
|
-
// legitimately announces a busy edge. It has no ordinary result event;
|
|
4721
|
-
// close that borrowed edge here after compactContext's finally clears
|
|
4722
|
-
// compactActive, or "Compacting…" degrades into stale "Thinking…".
|
|
4723
|
-
// A browser turn may have arrived while compactContext awaited App
|
|
4724
|
-
// Server readiness. Never let control cleanup settle that ordinary
|
|
4725
|
-
// turn's busy edge; the normal result boundary owns it.
|
|
4726
|
-
if (!s.session.turnActive && settleLaneControl(s)) announce()
|
|
4727
|
-
}
|
|
4728
|
-
if (nativeCompacted) {
|
|
4729
|
-
const ce = { kind: 'compaction', trigger: 'manual', preTokens, by: payload.by, native: true }
|
|
4730
|
-
pushLog(s, ce)
|
|
4731
|
-
bcast('code-event', { term: payload.term, evt: ce })
|
|
4732
|
-
bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
|
|
4733
|
-
s.flush?.()
|
|
4734
|
-
return
|
|
4735
|
-
}
|
|
4736
|
-
if (s.session.clearContext?.() === false) {
|
|
4737
|
-
bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
|
|
4738
|
-
ctlLine('Codex context compaction unavailable right now')
|
|
4998
|
+
s.pendingCodexCompaction = { by: payload.by, cid: payload.cid }
|
|
4999
|
+
ctlLine('Compaction queued for after this turn.', { tone: 'caution' })
|
|
4739
5000
|
return
|
|
4740
5001
|
}
|
|
4741
|
-
s.
|
|
4742
|
-
const ce = { kind: 'compaction', trigger: 'manual', preTokens, by: payload.by }
|
|
4743
|
-
pushLog(s, ce)
|
|
4744
|
-
bcast('code-event', { term: payload.term, evt: ce })
|
|
4745
|
-
// The new native thread has only the bounded recap queued for the next
|
|
4746
|
-
// human turn. Reset the visible meter to that conservative text estimate.
|
|
4747
|
-
if (s.lastUsage?.ctx?.max) {
|
|
4748
|
-
const used = Math.ceil(recap.length / 4)
|
|
4749
|
-
const max = s.lastUsage.ctx.max
|
|
4750
|
-
const usage = { kind: 'usage', ctx: { ...s.lastUsage.ctx, used, max, pct: Math.min(100, Math.round((used / max) * 100)), over: used > max } }
|
|
4751
|
-
s.lastUsage = usage
|
|
4752
|
-
bcast('code-event', { term: payload.term, evt: usage })
|
|
4753
|
-
}
|
|
4754
|
-
bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
|
|
4755
|
-
s.flush?.()
|
|
5002
|
+
startCodexCompaction(s, { by: payload.by, cid: payload.cid })
|
|
4756
5003
|
return
|
|
4757
5004
|
}
|
|
4758
5005
|
s.compacting = true
|
|
@@ -4989,7 +5236,7 @@ channel
|
|
|
4989
5236
|
// FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
|
|
4990
5237
|
// bridge restart: the conductor keeps its subagent-block + plan interception, and
|
|
4991
5238
|
// lanes keep their worktree cwd + the ability to mark done.
|
|
4992
|
-
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,
|
|
4993
5240
|
// Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
|
|
4994
5241
|
// shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
|
|
4995
5242
|
// (mid-turn needs auto-resume; flow needs its lane live).
|
|
@@ -5466,6 +5713,83 @@ designChannel
|
|
|
5466
5713
|
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') process.stderr.write(`\n ⚠ design realtime ${status} (tpdesign:${room}).\n`)
|
|
5467
5714
|
})
|
|
5468
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
|
+
|
|
5469
5793
|
// Watchdog — restart only for a Realtime-only wedge. A total network/Supabase
|
|
5470
5794
|
// outage cannot be repaired by killing the room process; doing so destroys local
|
|
5471
5795
|
// agent runtimes and leaves the supervisor unable to rediscover the room until HTTP
|