| ... | ... | @@ -61,9 +61,10 @@ const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000; |
| 61 | 61 | const DEFAULT_MAX_IDLE_PIN_MS = 4 * 60 * 60 * 1000; |
| 62 | 62 | const RELEASE_SCOPE_CLOSE_TIMEOUT_MS = 30 * 1000; |
| 63 | 63 | |
| 64 | const busyTurnPrefix = (providerThreadId: ProviderThreadId) => `${providerThreadId}#`; |
| 64 | 65 | /** The identity a turn's start and its `turn.terminal` share. */ |
| 65 | 66 | const busyTurnKey = (providerThreadId: ProviderThreadId, runOrdinal: number) => |
| 66 | | `${providerThreadId}#${runOrdinal}`; |
| 67 | `${busyTurnPrefix(providerThreadId)}${runOrdinal}`; |
| 67 | 68 | const UNLOAD_THREAD_TIMEOUT_MS = 10 * 1000; |
| 68 | 69 | |
| 69 | 70 | export const ProviderSessionReleaseReason = Schema.Literals([ |
| ... | ... | @@ -228,6 +229,18 @@ interface LiveSessionEntry { |
| 228 | 229 | readonly idleFiber: Fiber.Fiber<void, never> | null; |
| 229 | 230 | /** Set when idle release is deferred for pending background work; bounds total deferral. */ |
| 230 | 231 | readonly pinnedSinceMs: number | null; |
| 232 | /** |
| 233 | * Shared runtimes only: the provider thread each attached app thread last |
| 234 | * started a turn on, and the timer that unloads it once it has been idle |
| 235 | * for `idleTimeoutMs`. |
| 236 | */ |
| 237 | readonly idleThreadUnloads: ReadonlyMap<ThreadId, IdleThreadUnload>; |
| 238 | } |
| 239 | |
| 240 | interface IdleThreadUnload { |
| 241 | readonly providerThread: OrchestrationV2ProviderThread; |
| 242 | readonly generation: number; |
| 243 | readonly fiber: Fiber.Fiber<void, never> | null; |
| 231 | 244 | } |
| 232 | 245 | |
| 233 | 246 | type ProviderSessionEventSignal = |
| ... | ... | @@ -1400,7 +1413,11 @@ export const layerWithOptions = ( |
| 1400 | 1413 | // Clearing a turn that is not marked busy (one whose failed start already |
| 1401 | 1414 | // cleared it, or a subagent turn the manager never started) only |
| 1402 | 1415 | // records activity. |
| 1403 | | const markIdle = (providerSessionId: ProviderSessionId, turnKey: string) => |
| 1416 | const markIdle = ( |
| 1417 | providerSessionId: ProviderSessionId, |
| 1418 | providerThreadId: ProviderThreadId, |
| 1419 | runOrdinal: number, |
| 1420 | ) => |
| 1404 | 1421 | withActivityError( |
| 1405 | 1422 | providerSessionId, |
| 1406 | 1423 | Effect.gen(function* () { |
| ... | ... | @@ -1412,7 +1429,7 @@ export const layerWithOptions = ( |
| 1412 | 1429 | return current; |
| 1413 | 1430 | } |
| 1414 | 1431 | const busyTurns = new Set(entry.busyTurns); |
| 1415 | | busyTurns.delete(turnKey); |
| 1432 | busyTurns.delete(busyTurnKey(providerThreadId, runOrdinal)); |
| 1416 | 1433 | const updated = new Map(current); |
| 1417 | 1434 | updated.set(key, { |
| 1418 | 1435 | ...entry, |
| ... | ... | @@ -1422,9 +1439,181 @@ export const layerWithOptions = ( |
| 1422 | 1439 | return updated; |
| 1423 | 1440 | }); |
| 1424 | 1441 | yield* scheduleIdleReleaseInternal(providerSessionId); |
| 1442 | yield* scheduleThreadUnload(providerSessionId, providerThreadId); |
| 1425 | 1443 | }), |
| 1426 | 1444 | ); |
| 1427 | 1445 | |
| 1446 | const hasBusyTurn = (entry: LiveSessionEntry, providerThreadId: ProviderThreadId) => { |
| 1447 | const prefix = busyTurnPrefix(providerThreadId); |
| 1448 | for (const turnKey of entry.busyTurns) { |
| 1449 | if (turnKey.startsWith(prefix)) return true; |
| 1450 | } |
| 1451 | return false; |
| 1452 | }; |
| 1453 | |
| 1454 | const updateIdleThreadUnload = ( |
| 1455 | providerSessionId: ProviderSessionId, |
| 1456 | threadId: ThreadId, |
| 1457 | update: (current: IdleThreadUnload | undefined) => IdleThreadUnload | undefined, |
| 1458 | ) => |
| 1459 | Ref.modify(sessions, (current) => { |
| 1460 | const key = sessionKey(providerSessionId); |
| 1461 | const entry = current.get(key); |
| 1462 | if (entry === undefined) return [undefined, current] as const; |
| 1463 | const previous = entry.idleThreadUnloads.get(threadId); |
| 1464 | const next = update(previous); |
| 1465 | const idleThreadUnloads = new Map(entry.idleThreadUnloads); |
| 1466 | if (next === undefined) idleThreadUnloads.delete(threadId); |
| 1467 | else idleThreadUnloads.set(threadId, next); |
| 1468 | const updated = new Map(current); |
| 1469 | updated.set(key, { ...entry, idleThreadUnloads }); |
| 1470 | return [previous, updated] as const; |
| 1471 | }); |
| 1472 | |
| 1473 | /** |
| 1474 | * Starts tracking the provider thread an app thread runs its turns on, and |
| 1475 | * stops any unload pending for it. Called before the thread is resumed or |
| 1476 | * given a turn, so an unload cannot land between a resume that found the |
| 1477 | * thread loaded and the turn that relies on it. |
| 1478 | */ |
| 1479 | const holdThreadLoaded = (input: { |
| 1480 | readonly providerSessionId: ProviderSessionId; |
| 1481 | readonly threadId: ThreadId; |
| 1482 | readonly providerThread: OrchestrationV2ProviderThread; |
| 1483 | }) => |
| 1484 | Effect.gen(function* () { |
| 1485 | const entry = (yield* Ref.get(sessions)).get(sessionKey(input.providerSessionId)); |
| 1486 | if ( |
| 1487 | entry === undefined || |
| 1488 | !entry.supportsMultipleProviderThreads || |
| 1489 | entry.exposedRuntime.unloadThread === undefined || |
| 1490 | input.providerThread.nativeThreadRef === null |
| 1491 | ) { |
| 1492 | return; |
| 1493 | } |
| 1494 | const previous = yield* updateIdleThreadUnload( |
| 1495 | input.providerSessionId, |
| 1496 | input.threadId, |
| 1497 | (current) => ({ |
| 1498 | providerThread: input.providerThread, |
| 1499 | generation: (current?.generation ?? 0) + 1, |
| 1500 | fiber: null, |
| 1501 | }), |
| 1502 | ); |
| 1503 | yield* cancelIdleFiber(previous?.fiber ?? null); |
| 1504 | }); |
| 1505 | |
| 1506 | /** |
| 1507 | * A shared runtime never goes idle while any of its threads is in use, so |
| 1508 | * its threads get the idle timeout one by one: a thread with no turn for |
| 1509 | * `idleTimeoutMs` is unloaded from the runtime, along with the native MCP |
| 1510 | * servers it started. Its next turn's resume loads it again. |
| 1511 | */ |
| 1512 | const scheduleThreadUnload = ( |
| 1513 | providerSessionId: ProviderSessionId, |
| 1514 | providerThreadId: ProviderThreadId, |
| 1515 | ) => |
| 1516 | Effect.gen(function* () { |
| 1517 | const entry = (yield* Ref.get(sessions)).get(sessionKey(providerSessionId)); |
| 1518 | if (entry === undefined || hasBusyTurn(entry, providerThreadId)) return; |
| 1519 | const tracked = Array.from(entry.idleThreadUnloads).find( |
| 1520 | ([, pending]) => pending.providerThread.id === providerThreadId, |
| 1521 | ); |
| 1522 | if (tracked === undefined) return; |
| 1523 | const [threadId, pending] = tracked; |
| 1524 | const generation = pending.generation + 1; |
| 1525 | const fiber = yield* Effect.sleep(Duration.millis(idleTimeoutMs)).pipe( |
| 1526 | Effect.andThen(unloadIdleThread({ providerSessionId, threadId, generation })), |
| 1527 | Effect.forkIn(layerScope), |
| 1528 | ); |
| 1529 | // A turn that started meanwhile already moved the generation on. |
| 1530 | const previous = yield* updateIdleThreadUnload(providerSessionId, threadId, (current) => |
| 1531 | current?.generation === pending.generation |
| 1532 | ? { ...current, generation, fiber } |
| 1533 | : current, |
| 1534 | ); |
| 1535 | yield* cancelIdleFiber( |
| 1536 | previous?.generation === pending.generation ? previous.fiber : fiber, |
| 1537 | ); |
| 1538 | }); |
| 1539 | |
| 1540 | const unloadIdleThread = (input: { |
| 1541 | readonly providerSessionId: ProviderSessionId; |
| 1542 | readonly threadId: ThreadId; |
| 1543 | readonly generation: number; |
| 1544 | }): Effect.Effect<void> => |
| 1545 | Effect.gen(function* () { |
| 1546 | const outcome = yield* threadAttachment.withLock( |
| 1547 | threadAttachmentKey(input), |
| 1548 | Effect.gen(function* () { |
| 1549 | const key = sessionKey(input.providerSessionId); |
| 1550 | const entry = (yield* Ref.get(sessions)).get(key); |
| 1551 | const pending = entry?.idleThreadUnloads.get(input.threadId); |
| 1552 | const unloadThread = entry?.exposedRuntime.unloadThread; |
| 1553 | if ( |
| 1554 | entry === undefined || |
| 1555 | pending === undefined || |
| 1556 | pending.generation !== input.generation || |
| 1557 | unloadThread === undefined || |
| 1558 | !entry.attachedThreadIds.has(input.threadId) || |
| 1559 | hasBusyTurn(entry, pending.providerThread.id) |
| 1560 | ) { |
| 1561 | return "skipped" as const; |
| 1562 | } |
| 1563 | // Unloading stops the native thread's background terminals, so a |
| 1564 | // thread still running background work stays loaded. |
| 1565 | const hasPendingWork = |
| 1566 | entry.runtime.hasPendingBackgroundWorkForThread === undefined |
| 1567 | ? false |
| 1568 | : yield* entry.runtime |
| 1569 | .hasPendingBackgroundWorkForThread(pending.providerThread) |
| 1570 | .pipe(Effect.catchCause(() => Effect.succeed(false))); |
| 1571 | if (hasPendingWork) return "deferred" as const; |
| 1572 | const unloading = yield* Ref.modify(sessions, (current) => { |
| 1573 | const latest = current.get(key); |
| 1574 | if ( |
| 1575 | latest?.runtime !== entry.runtime || |
| 1576 | latest.idleThreadUnloads.get(input.threadId)?.generation !== input.generation |
| 1577 | ) { |
| 1578 | return [false, current] as const; |
| 1579 | } |
| 1580 | const loadedProviderThreadKeyByThread = new Map( |
| 1581 | latest.loadedProviderThreadKeyByThread, |
| 1582 | ); |
| 1583 | loadedProviderThreadKeyByThread.delete(input.threadId); |
| 1584 | const idleThreadUnloads = new Map(latest.idleThreadUnloads); |
| 1585 | idleThreadUnloads.delete(input.threadId); |
| 1586 | const updated = new Map(current); |
| 1587 | updated.set(key, { |
| 1588 | ...latest, |
| 1589 | loadedProviderThreadKeyByThread, |
| 1590 | idleThreadUnloads, |
| 1591 | }); |
| 1592 | return [true, updated] as const; |
| 1593 | }); |
| 1594 | if (!unloading) return "skipped" as const; |
| 1595 | yield* unloadThread({ providerThread: pending.providerThread }).pipe( |
| 1596 | Effect.timeout(UNLOAD_THREAD_TIMEOUT_MS), |
| 1597 | Effect.catchCause((cause) => |
| 1598 | Effect.logWarning("orchestration-v2.driver-session.idle-unload-failed", { |
| 1599 | providerSessionId: input.providerSessionId, |
| 1600 | threadId: input.threadId, |
| 1601 | providerThreadId: pending.providerThread.id, |
| 1602 | cause, |
| 1603 | }), |
| 1604 | ), |
| 1605 | ); |
| 1606 | return "unloaded" as const; |
| 1607 | }), |
| 1608 | ); |
| 1609 | // Re-check on this fiber after another idle window, outside the |
| 1610 | // lock so the thread's next attach is not held up meanwhile. |
| 1611 | if (outcome === "deferred") { |
| 1612 | yield* Effect.sleep(Duration.millis(idleTimeoutMs)); |
| 1613 | return yield* unloadIdleThread(input); |
| 1614 | } |
| 1615 | }); |
| 1616 | |
| 1428 | 1617 | const observeActivity = ( |
| 1429 | 1618 | providerSessionId: ProviderSessionId, |
| 1430 | 1619 | activity: Effect.Effect<void, ProviderSessionActivityError>, |
| ... | ... | @@ -1544,6 +1733,13 @@ export const layerWithOptions = ( |
| 1544 | 1733 | providerInstanceId: runtime.instanceId, |
| 1545 | 1734 | }), |
| 1546 | 1735 | ).pipe( |
| 1736 | Effect.andThen( |
| 1737 | holdThreadLoaded({ |
| 1738 | providerSessionId, |
| 1739 | threadId, |
| 1740 | providerThread: input.providerThread, |
| 1741 | }), |
| 1742 | ), |
| 1547 | 1743 | Effect.andThen( |
| 1548 | 1744 | isProviderThreadLoaded({ providerSessionId, threadId, providerThreadKey }), |
| 1549 | 1745 | ), |
| ... | ... | @@ -1607,6 +1803,13 @@ export const layerWithOptions = ( |
| 1607 | 1803 | // the adapter emits the terminal anyway, clearing the same turn |
| 1608 | 1804 | // again changes nothing, so another thread's turn on a shared |
| 1609 | 1805 | // session stays busy either way. |
| 1806 | Effect.andThen( |
| 1807 | holdThreadLoaded({ |
| 1808 | providerSessionId, |
| 1809 | threadId: input.threadId, |
| 1810 | providerThread: input.providerThread, |
| 1811 | }), |
| 1812 | ), |
| 1610 | 1813 | Effect.andThen( |
| 1611 | 1814 | Effect.acquireUseRelease( |
| 1612 | 1815 | observeActivity( |
| ... | ... | @@ -1622,10 +1825,7 @@ export const layerWithOptions = ( |
| 1622 | 1825 | Exit.isFailure(exit) |
| 1623 | 1826 | ? observeActivity( |
| 1624 | 1827 | providerSessionId, |
| 1625 | | markIdle( |
| 1626 | | providerSessionId, |
| 1627 | | busyTurnKey(input.providerThread.id, input.runOrdinal), |
| 1628 | | ), |
| 1828 | markIdle(providerSessionId, input.providerThread.id, input.runOrdinal), |
| 1629 | 1829 | ) |
| 1630 | 1830 | : Effect.void, |
| 1631 | 1831 | ), |
| ... | ... | @@ -1692,7 +1892,8 @@ export const layerWithOptions = ( |
| 1692 | 1892 | event.type === "turn.terminal" |
| 1693 | 1893 | ? markIdle( |
| 1694 | 1894 | entry.runtime.providerSessionId, |
| 1695 | | busyTurnKey(event.providerThreadId, event.runOrdinal), |
| 1895 | event.providerThreadId, |
| 1896 | event.runOrdinal, |
| 1696 | 1897 | ) |
| 1697 | 1898 | : touchActivity(entry.runtime.providerSessionId), |
| 1698 | 1899 | ).pipe( |
| ... | ... | @@ -1965,6 +2166,7 @@ export const layerWithOptions = ( |
| 1965 | 2166 | lastActivityAtMs: now, |
| 1966 | 2167 | idleFiber: null, |
| 1967 | 2168 | pinnedSinceMs: null, |
| 2169 | idleThreadUnloads: new Map(), |
| 1968 | 2170 | }; |
| 1969 | 2171 | yield* Ref.update(sessions, (current) => { |
| 1970 | 2172 | const updated = new Map(current); |
| ... | ... | @@ -2111,10 +2313,16 @@ export const layerWithOptions = ( |
| 2111 | 2313 | ); |
| 2112 | 2314 | } |
| 2113 | 2315 | } |
| 2114 | | const detached = yield* Ref.modify(sessions, (current) => { |
| 2316 | const detachResult = yield* Ref.modify(sessions, (current) => { |
| 2115 | 2317 | const entry = current.get(key); |
| 2116 | 2318 | if (entry === undefined || !entry.attachedThreadIds.has(input.threadId)) { |
| 2117 | | return [Option.none<LiveSessionEntry>(), current] as const; |
| 2319 | return [ |
| 2320 | Option.none<{ |
| 2321 | readonly entry: LiveSessionEntry; |
| 2322 | readonly idleUnloadFiber: Fiber.Fiber<void, never> | null; |
| 2323 | }>(), |
| 2324 | current, |
| 2325 | ] as const; |
| 2118 | 2326 | } |
| 2119 | 2327 | const attachedThreadIds = new Set(entry.attachedThreadIds); |
| 2120 | 2328 | attachedThreadIds.delete(input.threadId); |
| ... | ... | @@ -2135,16 +2343,30 @@ export const layerWithOptions = ( |
| 2135 | 2343 | return pruned; |
| 2136 | 2344 | })() |
| 2137 | 2345 | : entry.mcpCredentialIdByThread; |
| 2346 | // The detach unloads the thread itself below. |
| 2347 | const idleThreadUnloads = new Map(entry.idleThreadUnloads); |
| 2348 | idleThreadUnloads.delete(input.threadId); |
| 2138 | 2349 | const updatedEntry = { |
| 2139 | 2350 | ...entry, |
| 2140 | 2351 | attachedThreadIds, |
| 2141 | 2352 | loadedProviderThreadKeyByThread, |
| 2142 | 2353 | mcpCredentialIdByThread, |
| 2354 | idleThreadUnloads, |
| 2143 | 2355 | }; |
| 2144 | 2356 | const updated = new Map(current); |
| 2145 | 2357 | updated.set(key, updatedEntry); |
| 2146 | | return [Option.some(updatedEntry), updated] as const; |
| 2358 | return [ |
| 2359 | Option.some({ |
| 2360 | entry: updatedEntry, |
| 2361 | idleUnloadFiber: entry.idleThreadUnloads.get(input.threadId)?.fiber ?? null, |
| 2362 | }), |
| 2363 | updated, |
| 2364 | ] as const; |
| 2147 | 2365 | }); |
| 2366 | if (Option.isSome(detachResult)) { |
| 2367 | yield* cancelIdleFiber(detachResult.value.idleUnloadFiber); |
| 2368 | } |
| 2369 | const detached = Option.map(detachResult, (result) => result.entry); |
| 2148 | 2370 | // Plain detaches deliberately do not revoke: a detached thread's |
| 2149 | 2371 | // provider process may still be alive (shared multi-thread codex |
| 2150 | 2372 | // session across a workspace handoff) and holds its MCP client's |