| ... | @@ -61,9 +61,10 @@ const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000; | ... | @@ -61,9 +61,10 @@ const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000; |
| 61 | const DEFAULT_MAX_IDLE_PIN_MS = 4 * 60 * 60 * 1000; | 61 | const DEFAULT_MAX_IDLE_PIN_MS = 4 * 60 * 60 * 1000; |
| 62 | const RELEASE_SCOPE_CLOSE_TIMEOUT_MS = 30 * 1000; | 62 | const RELEASE_SCOPE_CLOSE_TIMEOUT_MS = 30 * 1000; |
| 63 | | 63 | |
| | 64 | const busyTurnPrefix = (providerThreadId: ProviderThreadId) => `${providerThreadId}#`; |
| 64 | /** The identity a turn's start and its `turn.terminal` share. */ | 65 | /** The identity a turn's start and its `turn.terminal` share. */ |
| 65 | const busyTurnKey = (providerThreadId: ProviderThreadId, runOrdinal: number) => | 66 | const busyTurnKey = (providerThreadId: ProviderThreadId, runOrdinal: number) => |
| 66 | `${providerThreadId}#${runOrdinal}`; | 67 | `${busyTurnPrefix(providerThreadId)}${runOrdinal}`; |
| 67 | const UNLOAD_THREAD_TIMEOUT_MS = 10 * 1000; | 68 | const UNLOAD_THREAD_TIMEOUT_MS = 10 * 1000; |
| 68 | | 69 | |
| 69 | export const ProviderSessionReleaseReason = Schema.Literals([ | 70 | export const ProviderSessionReleaseReason = Schema.Literals([ |
| ... | @@ -228,6 +229,18 @@ interface LiveSessionEntry { | ... | @@ -228,6 +229,18 @@ interface LiveSessionEntry { |
| 228 | readonly idleFiber: Fiber.Fiber<void, never> | null; | 229 | readonly idleFiber: Fiber.Fiber<void, never> | null; |
| 229 | /** Set when idle release is deferred for pending background work; bounds total deferral. */ | 230 | /** Set when idle release is deferred for pending background work; bounds total deferral. */ |
| 230 | readonly pinnedSinceMs: number | null; | 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 | type ProviderSessionEventSignal = | 246 | type ProviderSessionEventSignal = |
| ... | @@ -1400,7 +1413,11 @@ export const layerWithOptions = ( | ... | @@ -1400,7 +1413,11 @@ export const layerWithOptions = ( |
| 1400 | // Clearing a turn that is not marked busy (one whose failed start already | 1413 | // Clearing a turn that is not marked busy (one whose failed start already |
| 1401 | // cleared it, or a subagent turn the manager never started) only | 1414 | // cleared it, or a subagent turn the manager never started) only |
| 1402 | // records activity. | 1415 | // records activity. |
| 1403 | const markIdle = (providerSessionId: ProviderSessionId, turnKey: string) => | 1416 | const markIdle = ( |
| | 1417 | providerSessionId: ProviderSessionId, |
| | 1418 | providerThreadId: ProviderThreadId, |
| | 1419 | runOrdinal: number, |
| | 1420 | ) => |
| 1404 | withActivityError( | 1421 | withActivityError( |
| 1405 | providerSessionId, | 1422 | providerSessionId, |
| 1406 | Effect.gen(function* () { | 1423 | Effect.gen(function* () { |
| ... | @@ -1412,7 +1429,7 @@ export const layerWithOptions = ( | ... | @@ -1412,7 +1429,7 @@ export const layerWithOptions = ( |
| 1412 | return current; | 1429 | return current; |
| 1413 | } | 1430 | } |
| 1414 | const busyTurns = new Set(entry.busyTurns); | 1431 | const busyTurns = new Set(entry.busyTurns); |
| 1415 | busyTurns.delete(turnKey); | 1432 | busyTurns.delete(busyTurnKey(providerThreadId, runOrdinal)); |
| 1416 | const updated = new Map(current); | 1433 | const updated = new Map(current); |
| 1417 | updated.set(key, { | 1434 | updated.set(key, { |
| 1418 | ...entry, | 1435 | ...entry, |
| ... | @@ -1422,9 +1439,181 @@ export const layerWithOptions = ( | ... | @@ -1422,9 +1439,181 @@ export const layerWithOptions = ( |
| 1422 | return updated; | 1439 | return updated; |
| 1423 | }); | 1440 | }); |
| 1424 | yield* scheduleIdleReleaseInternal(providerSessionId); | 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 | const observeActivity = ( | 1617 | const observeActivity = ( |
| 1429 | providerSessionId: ProviderSessionId, | 1618 | providerSessionId: ProviderSessionId, |
| 1430 | activity: Effect.Effect<void, ProviderSessionActivityError>, | 1619 | activity: Effect.Effect<void, ProviderSessionActivityError>, |
| ... | @@ -1544,6 +1733,13 @@ export const layerWithOptions = ( | ... | @@ -1544,6 +1733,13 @@ export const layerWithOptions = ( |
| 1544 | providerInstanceId: runtime.instanceId, | 1733 | providerInstanceId: runtime.instanceId, |
| 1545 | }), | 1734 | }), |
| 1546 | ).pipe( | 1735 | ).pipe( |
| | 1736 | Effect.andThen( |
| | 1737 | holdThreadLoaded({ |
| | 1738 | providerSessionId, |
| | 1739 | threadId, |
| | 1740 | providerThread: input.providerThread, |
| | 1741 | }), |
| | 1742 | ), |
| 1547 | Effect.andThen( | 1743 | Effect.andThen( |
| 1548 | isProviderThreadLoaded({ providerSessionId, threadId, providerThreadKey }), | 1744 | isProviderThreadLoaded({ providerSessionId, threadId, providerThreadKey }), |
| 1549 | ), | 1745 | ), |
| ... | @@ -1607,6 +1803,13 @@ export const layerWithOptions = ( | ... | @@ -1607,6 +1803,13 @@ export const layerWithOptions = ( |
| 1607 | // the adapter emits the terminal anyway, clearing the same turn | 1803 | // the adapter emits the terminal anyway, clearing the same turn |
| 1608 | // again changes nothing, so another thread's turn on a shared | 1804 | // again changes nothing, so another thread's turn on a shared |
| 1609 | // session stays busy either way. | 1805 | // session stays busy either way. |
| | 1806 | Effect.andThen( |
| | 1807 | holdThreadLoaded({ |
| | 1808 | providerSessionId, |
| | 1809 | threadId: input.threadId, |
| | 1810 | providerThread: input.providerThread, |
| | 1811 | }), |
| | 1812 | ), |
| 1610 | Effect.andThen( | 1813 | Effect.andThen( |
| 1611 | Effect.acquireUseRelease( | 1814 | Effect.acquireUseRelease( |
| 1612 | observeActivity( | 1815 | observeActivity( |
| ... | @@ -1622,10 +1825,7 @@ export const layerWithOptions = ( | ... | @@ -1622,10 +1825,7 @@ export const layerWithOptions = ( |
| 1622 | Exit.isFailure(exit) | 1825 | Exit.isFailure(exit) |
| 1623 | ? observeActivity( | 1826 | ? observeActivity( |
| 1624 | providerSessionId, | 1827 | providerSessionId, |
| 1625 | markIdle( | 1828 | markIdle(providerSessionId, input.providerThread.id, input.runOrdinal), |
| 1626 | providerSessionId, | | |
| 1627 | busyTurnKey(input.providerThread.id, input.runOrdinal), | | |
| 1628 | ), | | |
| 1629 | ) | 1829 | ) |
| 1630 | : Effect.void, | 1830 | : Effect.void, |
| 1631 | ), | 1831 | ), |
| ... | @@ -1692,7 +1892,8 @@ export const layerWithOptions = ( | ... | @@ -1692,7 +1892,8 @@ export const layerWithOptions = ( |
| 1692 | event.type === "turn.terminal" | 1892 | event.type === "turn.terminal" |
| 1693 | ? markIdle( | 1893 | ? markIdle( |
| 1694 | entry.runtime.providerSessionId, | 1894 | entry.runtime.providerSessionId, |
| 1695 | busyTurnKey(event.providerThreadId, event.runOrdinal), | 1895 | event.providerThreadId, |
| | 1896 | event.runOrdinal, |
| 1696 | ) | 1897 | ) |
| 1697 | : touchActivity(entry.runtime.providerSessionId), | 1898 | : touchActivity(entry.runtime.providerSessionId), |
| 1698 | ).pipe( | 1899 | ).pipe( |
| ... | @@ -1965,6 +2166,7 @@ export const layerWithOptions = ( | ... | @@ -1965,6 +2166,7 @@ export const layerWithOptions = ( |
| 1965 | lastActivityAtMs: now, | 2166 | lastActivityAtMs: now, |
| 1966 | idleFiber: null, | 2167 | idleFiber: null, |
| 1967 | pinnedSinceMs: null, | 2168 | pinnedSinceMs: null, |
| | 2169 | idleThreadUnloads: new Map(), |
| 1968 | }; | 2170 | }; |
| 1969 | yield* Ref.update(sessions, (current) => { | 2171 | yield* Ref.update(sessions, (current) => { |
| 1970 | const updated = new Map(current); | 2172 | const updated = new Map(current); |
| ... | @@ -2111,10 +2313,16 @@ export const layerWithOptions = ( | ... | @@ -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 | const entry = current.get(key); | 2317 | const entry = current.get(key); |
| 2116 | if (entry === undefined || !entry.attachedThreadIds.has(input.threadId)) { | 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 | const attachedThreadIds = new Set(entry.attachedThreadIds); | 2327 | const attachedThreadIds = new Set(entry.attachedThreadIds); |
| 2120 | attachedThreadIds.delete(input.threadId); | 2328 | attachedThreadIds.delete(input.threadId); |
| ... | @@ -2135,16 +2343,30 @@ export const layerWithOptions = ( | ... | @@ -2135,16 +2343,30 @@ export const layerWithOptions = ( |
| 2135 | return pruned; | 2343 | return pruned; |
| 2136 | })() | 2344 | })() |
| 2137 | : entry.mcpCredentialIdByThread; | 2345 | : entry.mcpCredentialIdByThread; |
| | 2346 | // The detach unloads the thread itself below. |
| | 2347 | const idleThreadUnloads = new Map(entry.idleThreadUnloads); |
| | 2348 | idleThreadUnloads.delete(input.threadId); |
| 2138 | const updatedEntry = { | 2349 | const updatedEntry = { |
| 2139 | ...entry, | 2350 | ...entry, |
| 2140 | attachedThreadIds, | 2351 | attachedThreadIds, |
| 2141 | loadedProviderThreadKeyByThread, | 2352 | loadedProviderThreadKeyByThread, |
| 2142 | mcpCredentialIdByThread, | 2353 | mcpCredentialIdByThread, |
| | 2354 | idleThreadUnloads, |
| 2143 | }; | 2355 | }; |
| 2144 | const updated = new Map(current); | 2356 | const updated = new Map(current); |
| 2145 | updated.set(key, updatedEntry); | 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 | // Plain detaches deliberately do not revoke: a detached thread's | 2370 | // Plain detaches deliberately do not revoke: a detached thread's |
| 2149 | // provider process may still be alive (shared multi-thread codex | 2371 | // provider process may still be alive (shared multi-thread codex |
| 2150 | // session across a workspace handoff) and holds its MCP client's | 2372 | // session across a workspace handoff) and holds its MCP client's |