@gajae-code/coding-agent 0.11.0 → 0.11.1

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/dist/types/async/job-manager.d.ts +46 -7
  3. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +11 -0
  4. package/dist/types/modes/controllers/command-controller.d.ts +2 -2
  5. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -0
  6. package/dist/types/modes/interactive-mode.d.ts +2 -2
  7. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +1 -0
  8. package/dist/types/modes/types.d.ts +2 -2
  9. package/dist/types/sdk/broker/ensure.d.ts +9 -0
  10. package/dist/types/sdk/bus/discord-daemon.d.ts +5 -0
  11. package/dist/types/sdk/bus/telegram-daemon.d.ts +23 -0
  12. package/dist/types/tools/subagent-render.d.ts +1 -0
  13. package/dist/types/tools/subagent.d.ts +6 -1
  14. package/package.json +8 -8
  15. package/scripts/build-sdk-package-smoke.ts +5 -0
  16. package/src/async/job-manager.ts +406 -63
  17. package/src/gjc-runtime/ultragoal-guard.ts +55 -8
  18. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +102 -1
  19. package/src/gjc-runtime/ultragoal-runtime.ts +72 -8
  20. package/src/internal-urls/docs-index.generated.ts +2 -2
  21. package/src/modes/controllers/command-controller.ts +9 -15
  22. package/src/modes/controllers/extension-ui-controller.ts +48 -24
  23. package/src/modes/interactive-mode.ts +28 -16
  24. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +7 -3
  25. package/src/modes/types.ts +2 -2
  26. package/src/sdk/broker/discovery.ts +20 -1
  27. package/src/sdk/broker/ensure.ts +82 -24
  28. package/src/sdk/bus/chat-effect-journal.ts +6 -4
  29. package/src/sdk/bus/discord-daemon.ts +25 -8
  30. package/src/sdk/bus/index.ts +57 -21
  31. package/src/sdk/bus/native-runtime-compatibility.ts +1 -1
  32. package/src/sdk/bus/telegram-daemon.ts +102 -10
  33. package/src/session/agent-session.ts +176 -29
  34. package/src/task/index.ts +26 -16
  35. package/src/tools/subagent-render.ts +19 -8
  36. package/src/tools/subagent.ts +84 -32
@@ -874,6 +874,10 @@ interface SessionRuntime {
874
874
  redact: boolean;
875
875
  /** True only after the exact host generation was registered with the broker index. */
876
876
  brokerRegistrationActive: boolean;
877
+ /** Terminal cleanup proof retained across retries; each owner is released at most once after proof. */
878
+ hostStopped: boolean;
879
+ serverStopped: boolean;
880
+ brokerRegistrationReleased: boolean;
877
881
  verbosity: "lean" | "verbose";
878
882
  sessionTag: string;
879
883
  /** Whether the agent loop is currently running (drives the typing indicator). */
@@ -2294,6 +2298,9 @@ export function createNotificationsExtension(
2294
2298
  getConfig: () => resolveSettings(options.settings).cfg,
2295
2299
  });
2296
2300
 
2301
+ // Failed terminal teardown remains fenced from normal runtime lookup while the
2302
+ // exact runtime object retains authority for an explicit idempotent retry.
2303
+ const cleanupRetries = new Map<string, SessionRuntime>();
2297
2304
  const sessionStartPromises = new Map<string, Promise<SessionStartResult>>();
2298
2305
  let activeRuntimeId: string | undefined;
2299
2306
  let identityControlInFlight = false;
@@ -2341,13 +2348,15 @@ export function createNotificationsExtension(
2341
2348
  reason: "session" | "notifications" = "session",
2342
2349
  expectedRuntime?: SessionRuntime,
2343
2350
  ): Promise<boolean> {
2344
- const requestedRuntime = runtimes.get(id);
2351
+ const retryRuntime = cleanupRetries.get(id);
2352
+ const activeRuntime = runtimes.get(id);
2353
+ const requestedRuntime = retryRuntime ?? activeRuntime;
2345
2354
  if (expectedRuntime && requestedRuntime !== expectedRuntime) return false;
2346
2355
  if (reason === "session" && requestedRuntime) requestedRuntime.stopping = true;
2347
2356
  if (reason === "session" && requestedRuntime) {
2348
2357
  // Fence the exact runtime before awaiting its startup promise: a late start
2349
2358
  // must observe removal and clean itself up rather than becoming reachable.
2350
- runtimes.delete(id);
2359
+ if (runtimes.get(id) === requestedRuntime) runtimes.delete(id);
2351
2360
  if (activeRuntimeId === id) activeRuntimeId = undefined;
2352
2361
  }
2353
2362
  const pendingStart = sessionStartPromises.get(id);
@@ -2355,7 +2364,8 @@ export function createNotificationsExtension(
2355
2364
  void pendingStart
2356
2365
  .catch(() => {})
2357
2366
  .then(() => {
2358
- if (runtimes.get(id) === requestedRuntime) void stopSession(id, reason, requestedRuntime);
2367
+ if (runtimes.get(id) === requestedRuntime || cleanupRetries.get(id) === requestedRuntime)
2368
+ void stopSession(id, reason, requestedRuntime);
2359
2369
  });
2360
2370
  const rt = requestedRuntime;
2361
2371
 
@@ -2378,6 +2388,9 @@ export function createNotificationsExtension(
2378
2388
  rt.pendingInteractive.clear();
2379
2389
  return true;
2380
2390
  }
2391
+ // Keep this exact object authoritative for the full terminal release, including
2392
+ // the interval before a failed owner can be recorded for a later retry.
2393
+ cleanupRetries.set(id, rt);
2381
2394
 
2382
2395
  try {
2383
2396
  rt.cancelPostmortemCleanup();
@@ -2405,16 +2418,24 @@ export function createNotificationsExtension(
2405
2418
  try {
2406
2419
  rt.disposeGateTerminalController();
2407
2420
  } catch {}
2408
- let hostStopped = false;
2409
- let brokerRegistrationReleased = false;
2421
+ let hostStopped = rt.hostStopped;
2422
+ let brokerRegistrationReleased = rt.brokerRegistrationReleased;
2423
+ const ownerReleaseFailures: unknown[] = [];
2410
2424
 
2411
- try {
2412
- const stopped = await rt.host.stop();
2413
- hostStopped = stopped === "stopped";
2414
- brokerRegistrationReleased = !rt.brokerRegistrationActive || stopped === "stopped";
2415
- if (rt.brokerRegistrationActive && stopped === "stopped") rt.brokerRegistrationActive = false;
2416
- } catch (e) {
2417
- logger.warn(`sdk host: stop failed: ${String(e)}`);
2425
+ if (!hostStopped) {
2426
+ try {
2427
+ const stopped = await rt.host.stop();
2428
+ hostStopped = stopped === "stopped";
2429
+ brokerRegistrationReleased = !rt.brokerRegistrationActive || hostStopped;
2430
+ if (rt.brokerRegistrationActive && hostStopped) rt.brokerRegistrationActive = false;
2431
+ if (hostStopped) {
2432
+ rt.hostStopped = true;
2433
+ rt.brokerRegistrationReleased = brokerRegistrationReleased;
2434
+ }
2435
+ } catch (e) {
2436
+ ownerReleaseFailures.push(e);
2437
+ logger.warn(`sdk host: stop failed: ${String(e)}`);
2438
+ }
2418
2439
  }
2419
2440
  rt.host.reverse.dispose();
2420
2441
  // Resolve any still-pending interactive asks so the ask tool is not left hanging.
@@ -2424,6 +2445,7 @@ export function createNotificationsExtension(
2424
2445
  rt.cursors.close();
2425
2446
  await rt.revisions.close();
2426
2447
  } catch (e) {
2448
+ ownerReleaseFailures.push(e);
2427
2449
  logger.warn(`sdk query snapshots: close failed: ${String(e)}`);
2428
2450
  }
2429
2451
  let closeFrameSent = false;
@@ -2434,18 +2456,27 @@ export function createNotificationsExtension(
2434
2456
  logger.warn(`notifications: session_closed failed: ${String(e)}`);
2435
2457
  }
2436
2458
  if (closeFrameSent) await sleep(100);
2437
- let serverStopped = false;
2438
- try {
2439
- rt.server.stop();
2440
- serverStopped = true;
2441
- } catch (e) {
2442
- logger.warn(`notifications: stop failed: ${String(e)}`);
2459
+ let serverStopped = rt.serverStopped;
2460
+ if (!serverStopped) {
2461
+ try {
2462
+ await rt.server.stopAndWait();
2463
+ serverStopped = true;
2464
+ rt.serverStopped = true;
2465
+ } catch (e) {
2466
+ ownerReleaseFailures.push(e);
2467
+ logger.warn(`notifications: stop failed: ${String(e)}`);
2468
+ }
2443
2469
  }
2444
2470
  lifecycleStartupCapability?.rollback?.recordStop(rt.host.generation, {
2445
2471
  runtimeRemoved: true,
2446
- hostStopped: hostStopped && serverStopped,
2447
- brokerRegistrationReleased,
2472
+ hostStopped: rt.hostStopped && rt.serverStopped,
2473
+ brokerRegistrationReleased: rt.brokerRegistrationReleased,
2448
2474
  });
2475
+ if (ownerReleaseFailures.length > 0) {
2476
+ cleanupRetries.set(id, rt);
2477
+ throw new AggregateError(ownerReleaseFailures, `SDK notification runtime ${id} owner release failed.`);
2478
+ }
2479
+ if (cleanupRetries.get(id) === rt) cleanupRetries.delete(id);
2449
2480
  return true;
2450
2481
  }
2451
2482
 
@@ -2484,6 +2515,8 @@ export function createNotificationsExtension(
2484
2515
  if (lifecycleRequired && !isNotificationEligibleContext(ctx)) return failLifecycleStartup("ineligible");
2485
2516
  const pendingStart = sessionStartPromises.get(id);
2486
2517
  if (pendingStart) return pendingStart;
2518
+ const retainedCleanup = cleanupRetries.get(id);
2519
+ if (retainedCleanup) return failLifecycleStartup("failed", "SDK notification runtime cleanup is still pending.");
2487
2520
  const existingRuntime = runtimes.get(id);
2488
2521
  if (existingRuntime) {
2489
2522
  activeRuntimeId = id;
@@ -2930,6 +2963,9 @@ export function createNotificationsExtension(
2930
2963
  idleSeq: 0,
2931
2964
  pendingInteractive,
2932
2965
  brokerRegistrationActive: false,
2966
+ hostStopped: false,
2967
+ serverStopped: false,
2968
+ brokerRegistrationReleased: false,
2933
2969
  disposeAnswerSource: () => {},
2934
2970
  disposeFileSink: () => {},
2935
2971
  disposeGateListener: () => {},
@@ -2980,7 +3016,7 @@ export function createNotificationsExtension(
2980
3016
  };
2981
3017
  const cleanupAbandonedStartup = async (): Promise<void> => {
2982
3018
  try {
2983
- server.stop();
3019
+ await server.stopAndWait();
2984
3020
  } catch {}
2985
3021
  try {
2986
3022
  await host.stop();
@@ -1,4 +1,4 @@
1
- const REQUIRED_WORKFLOW_ARBITRATION_METHODS = ["registerArbitratedAsk", "retireIfUnclaimed"] as const;
1
+ const REQUIRED_WORKFLOW_ARBITRATION_METHODS = ["registerArbitratedAsk", "retireIfUnclaimed", "stopAndWait"] as const;
2
2
 
3
3
  export class NativeRuntimeCompatibilityError extends Error {
4
4
  readonly code = "native_runtime_incompatible";
@@ -941,23 +941,96 @@ type PairedChatPrivacy = "private" | "non-private" | "indeterminate";
941
941
 
942
942
  export type TelegramUpdateOutcome = "consumed" | "retry";
943
943
 
944
+ export type TelegramPollResult =
945
+ | { kind: "success"; updateCount: number }
946
+ | { kind: "aborted" }
947
+ | { kind: "getUpdates_failed"; error: string }
948
+ | { kind: "api_failure"; errorCode?: number; description: string }
949
+ | { kind: "conflict"; description: string; backoffMs: number };
950
+
944
951
  export interface TelegramUpdatePollerOptions {
945
952
  botApi: BotApi;
946
953
  runtime: NotificationOperatorRuntime;
947
954
  backoff: OperatorBackoffPolicy;
948
955
  processUpdate: (update: unknown) => Promise<TelegramUpdateOutcome>;
956
+ health?: TelegramPollHealth;
957
+ }
958
+
959
+ type TelegramPollHealthStatus = "healthy" | "getUpdates_failed" | "api_failure" | "conflict";
960
+
961
+ export class TelegramPollHealth {
962
+ #status: TelegramPollHealthStatus = "healthy";
963
+ #suppressedCount = 0;
964
+
965
+ record(result: TelegramPollResult): void {
966
+ if (result.kind === "aborted") return;
967
+ if (result.kind === "success") {
968
+ if (this.#status !== "healthy") {
969
+ logger.info("notifications daemon: Telegram getUpdates recovered", {
970
+ from: this.#status,
971
+ suppressedCount: this.#suppressedCount,
972
+ updateCount: result.updateCount,
973
+ });
974
+ }
975
+ this.#status = "healthy";
976
+ this.#suppressedCount = 0;
977
+ return;
978
+ }
979
+
980
+ const previousStatus = this.#status;
981
+ if (previousStatus === result.kind) {
982
+ this.#suppressedCount += 1;
983
+ return;
984
+ }
985
+
986
+ const suppressedCount = this.#suppressedCount;
987
+ this.#status = result.kind;
988
+ this.#suppressedCount = 0;
989
+ if (result.kind === "conflict") {
990
+ logger.error("notifications daemon: Telegram getUpdates 409 conflict", {
991
+ description: result.description,
992
+ backoffMs: result.backoffMs,
993
+ previousStatus,
994
+ suppressedCount,
995
+ });
996
+ return;
997
+ }
998
+ if (result.kind === "api_failure") {
999
+ logger.error("notifications daemon: Telegram getUpdates API failed", {
1000
+ errorCode: result.errorCode,
1001
+ description: result.description,
1002
+ previousStatus,
1003
+ suppressedCount,
1004
+ });
1005
+ return;
1006
+ }
1007
+
1008
+ logger.error("notifications daemon: getUpdates failed", {
1009
+ error: result.error,
1010
+ previousStatus,
1011
+ suppressedCount,
1012
+ });
1013
+ }
949
1014
  }
950
1015
 
951
1016
  /** Owns getUpdates offset, conflict backoff, and per-update error isolation. */
952
1017
  export class TelegramUpdatePoller {
953
1018
  #offset = 0;
954
1019
  #opts: TelegramUpdatePollerOptions;
1020
+ #health: TelegramPollHealth;
955
1021
 
956
1022
  constructor(opts: TelegramUpdatePollerOptions) {
957
1023
  this.#opts = opts;
1024
+ this.#health = opts.health ?? new TelegramPollHealth();
958
1025
  }
959
1026
 
960
1027
  async pollOnce(signal?: AbortSignal): Promise<number> {
1028
+ const result = await this.pollOnceResult(signal);
1029
+ this.#health.record(result);
1030
+ return result.kind === "success" ? result.updateCount : 0;
1031
+ }
1032
+
1033
+ async pollOnceResult(signal?: AbortSignal): Promise<TelegramPollResult> {
961
1034
  let body: {
962
1035
  ok?: boolean;
963
1036
  error_code?: number;
@@ -972,24 +1045,37 @@ export class TelegramUpdatePoller {
972
1045
  )) as typeof body;
973
1046
  } catch (err) {
974
1047
  // A cooperative stop aborts the in-flight long poll; treat as a clean wake.
975
- if (isAbortError(err)) return 0;
1048
+ if (isAbortError(err)) return { kind: "aborted" };
976
1049
  // A transient Telegram API failure must never crash the daemon.
977
- logger.error("notifications daemon: getUpdates failed", { error: sanitizeDiagnostic(String(err)) });
978
1050
  await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
979
- return 0;
1051
+ return { kind: "getUpdates_failed", error: sanitizeDiagnostic(String(err)) };
980
1052
  }
981
1053
  // Telegram allows only one active getUpdates poller per bot. A 409 means
982
1054
  // another poller is live; back off boundedly instead of hot-looping.
983
1055
  if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
984
1056
  const backoffMs = this.#opts.backoff.next();
985
- logger.error(
986
- `notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${backoffMs}ms`,
987
- );
988
1057
  await this.#opts.runtime.sleep(backoffMs, signal);
989
- return 0;
1058
+ return { kind: "conflict", description: sanitizeDiagnostic(body.description ?? "no description"), backoffMs };
1059
+ }
1060
+ if (body?.ok !== true || !Array.isArray(body.result)) {
1061
+ await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
1062
+ return {
1063
+ kind: "api_failure",
1064
+ errorCode: typeof body?.error_code === "number" ? body.error_code : undefined,
1065
+ description: sanitizeDiagnostic(body?.description ?? "Malformed getUpdates response"),
1066
+ };
990
1067
  }
991
1068
  this.#opts.backoff.reset();
992
- for (const update of body.result ?? []) {
1069
+ let malformedSeen = false;
1070
+ for (const update of body.result) {
1071
+ // A single malformed update_id must not wedge the poller. Skip the
1072
+ // bad entry (surfaced below as an api_failure health signal) while
1073
+ // still processing valid updates and advancing the offset past them,
1074
+ // so one poisoned item cannot stall an otherwise-valid stream.
1075
+ if (!Number.isSafeInteger(update?.update_id)) {
1076
+ malformedSeen = true;
1077
+ continue;
1078
+ }
993
1079
  try {
994
1080
  const outcome = await this.#opts.processUpdate(update);
995
1081
  if (outcome === "retry") {
@@ -998,11 +1084,17 @@ export class TelegramUpdatePoller {
998
1084
  }
999
1085
  this.#offset = update.update_id + 1;
1000
1086
  } catch (err) {
1001
- logger.error("notifications daemon: handleTelegramUpdate failed", { error: String(err) });
1087
+ logger.error("notifications daemon: handleTelegramUpdate failed", {
1088
+ error: sanitizeDiagnostic(String(err)),
1089
+ });
1002
1090
  this.#offset = update.update_id + 1;
1003
1091
  }
1004
1092
  }
1005
- return body.result?.length ?? 0;
1093
+ if (malformedSeen) {
1094
+ await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
1095
+ return { kind: "api_failure", description: "Malformed getUpdates response" };
1096
+ }
1097
+ return { kind: "success", updateCount: body.result.length };
1006
1098
  }
1007
1099
  }
1008
1100
 
@@ -57,6 +57,8 @@ import {
57
57
  generateHandoff,
58
58
  IMAGE_TOKEN_ESTIMATE,
59
59
  prepareCompaction,
60
+ type RemoteCompactionFallbackHealthEvent,
61
+ type RemoteCompactionFallbackHealthHooks,
60
62
  type SummaryOptions,
61
63
  shouldCompact,
62
64
  } from "@gajae-code/agent-core/compaction";
@@ -641,6 +643,40 @@ interface TemporaryProviderSessionScopeRecord {
641
643
  providerSessionState: Map<string, ProviderSessionState>;
642
644
  }
643
645
 
646
+ class RemoteCompactionFallbackHealth implements RemoteCompactionFallbackHealthHooks {
647
+ #status: "healthy" | "fallback" = "healthy";
648
+ #suppressedCount = 0;
649
+
650
+ recordRemoteCompactionFallback(event: RemoteCompactionFallbackHealthEvent): void {
651
+ if (event.kind === "success") {
652
+ if (this.#status === "fallback") {
653
+ logger.info("OpenAI remote compaction recovered", {
654
+ model: event.model,
655
+ provider: event.provider,
656
+ suppressedCount: this.#suppressedCount,
657
+ });
658
+ }
659
+ this.#status = "healthy";
660
+ this.#suppressedCount = 0;
661
+ return;
662
+ }
663
+
664
+ if (this.#status === "fallback") {
665
+ this.#suppressedCount += 1;
666
+ return;
667
+ }
668
+
669
+ this.#status = "fallback";
670
+ this.#suppressedCount = 0;
671
+ logger.warn("OpenAI remote compaction failed, falling back to local summarization", {
672
+ error: event.error,
673
+ model: event.model,
674
+ provider: event.provider,
675
+ suppressedCount: 0,
676
+ });
677
+ }
678
+ }
679
+
644
680
  /** Result from cycleRoleModels() */
645
681
  export interface RoleModelCycleResult {
646
682
  model: Model;
@@ -1419,6 +1455,7 @@ export class AgentSession {
1419
1455
  #providerCacheSessionId: string | undefined;
1420
1456
  #isDisposed = false;
1421
1457
  #disposePromise: Promise<void> | undefined;
1458
+ #newSessionTransition: Promise<boolean> | undefined;
1422
1459
  // Extension system
1423
1460
  #extensionRunner: ExtensionRunner | undefined = undefined;
1424
1461
 
@@ -1544,6 +1581,7 @@ export class AgentSession {
1544
1581
  }
1545
1582
  >();
1546
1583
 
1584
+ #remoteCompactionFallbackHealth = new RemoteCompactionFallbackHealth();
1547
1585
  #streamingEditPrecheckedToolCallIds = new Set<string>();
1548
1586
 
1549
1587
  #streamingEditFileCache = new StreamingEditFileCache();
@@ -7621,7 +7659,20 @@ export class AgentSession {
7621
7659
  * @param options - Optional initial messages and parent session path
7622
7660
  * @returns true if completed, false if cancelled by hook
7623
7661
  */
7624
- async newSession(options?: NewSessionOptions): Promise<boolean> {
7662
+ newSession(options?: NewSessionOptions): Promise<boolean> {
7663
+ if (this.#newSessionTransition) return this.#newSessionTransition;
7664
+
7665
+ const transition = this.#runNewSessionTransition(options);
7666
+ this.#newSessionTransition = transition;
7667
+ void transition
7668
+ .finally(() => {
7669
+ if (this.#newSessionTransition === transition) this.#newSessionTransition = undefined;
7670
+ })
7671
+ .catch(() => {});
7672
+ return transition;
7673
+ }
7674
+
7675
+ async #runNewSessionTransition(options?: NewSessionOptions): Promise<boolean> {
7625
7676
  const previousSessionFile = this.sessionFile;
7626
7677
  const previousWorkflowGateSessionId = this.sessionId;
7627
7678
  const nextDiscoverySessionToolNames = this.#mcpDiscoveryEnabled
@@ -7643,32 +7694,132 @@ export class AgentSession {
7643
7694
  }
7644
7695
  }
7645
7696
 
7646
- this.#disconnectFromAgent();
7647
- await this.abort();
7648
- this.#cancelOwnAsyncJobs();
7649
- this.#closeAllProviderSessions("new session");
7650
- this.#rebindProviderSessionState(new Map());
7651
- this.agent.reset();
7652
- if (options?.drop && previousSessionFile) {
7697
+ const manager = AsyncJobManager.instance();
7698
+ const ownerId = this.#agentId;
7699
+ const lease = manager && ownerId ? manager.beginOwnerSubagentShutdown(ownerId) : undefined;
7700
+ if (manager && ownerId && !lease) {
7701
+ this.emitNotice(
7702
+ "error",
7703
+ "Cannot start a new session while owned subagent cleanup is already in progress.",
7704
+ "new-session-subagent-cleanup",
7705
+ );
7706
+ return false;
7707
+ }
7708
+
7709
+ if (!lease) {
7710
+ this.#disconnectFromAgent();
7711
+ await this.abort();
7712
+ if (this.isCompacting) {
7713
+ this.abortCompaction();
7714
+ while (this.isCompacting) {
7715
+ await Bun.sleep(10);
7716
+ }
7717
+ }
7718
+ this.#cancelOwnAsyncJobs();
7719
+ this.#closeAllProviderSessions("new session");
7720
+ this.#rebindProviderSessionState(new Map());
7721
+ this.agent.reset();
7722
+ if (!options?.drop) await this.sessionManager.flush();
7723
+ await this.sessionManager.newSession(options);
7724
+ this.setTodoPhases([]);
7725
+ this.#syncAgentSessionId();
7726
+ this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
7727
+ this.#rekeyHindsightMemoryForCurrentSessionId();
7728
+ this.#resetHindsightConversationTrackingIfHindsight();
7729
+ this.#steeringMessages = [];
7730
+ this.#followUpMessages = [];
7731
+ this.#pendingNextTurnMessages = [];
7732
+ this.#scheduledHiddenNextTurnGeneration = undefined;
7733
+ await this.#initializeNewSessionState(nextDiscoverySessionToolNames, previousSessionFile);
7734
+ if (options?.drop && previousSessionFile) {
7735
+ try {
7736
+ await this.sessionManager.dropSession(previousSessionFile);
7737
+ } catch (err) {
7738
+ logger.error("Failed to delete session during /drop", { err });
7739
+ }
7740
+ }
7741
+ return true;
7742
+ }
7743
+
7744
+ if (!manager) throw new Error("Owner subagent shutdown manager became unavailable.");
7745
+ if (!ownerId) throw new Error("Owner subagent shutdown owner became unavailable.");
7746
+ const previousSessionIdentity = this.sessionManager.getSessionId();
7747
+ try {
7653
7748
  try {
7654
- await this.sessionManager.dropSession(previousSessionFile);
7655
- } catch (err) {
7656
- logger.error("Failed to delete session during /drop", { err });
7749
+ manager.runOwnerProducerCleanupsStrict({ ownerId });
7750
+ await this.abort();
7751
+ if (this.isCompacting) {
7752
+ this.abortCompaction();
7753
+ while (this.isCompacting) {
7754
+ await Bun.sleep(10);
7755
+ }
7756
+ }
7757
+ const proof = await manager.cancelAndProveOwnerSubagents(lease);
7758
+ if (!proof.confirmed) {
7759
+ this.emitNotice(
7760
+ "error",
7761
+ "Unable to confirm owned subagent cleanup; session was not replaced. Wait for or inspect remaining subagents, then retry /new.",
7762
+ "new-session-subagent-cleanup",
7763
+ );
7764
+ manager.finishOwnerSubagentShutdown(lease, "release");
7765
+ return false;
7766
+ }
7767
+ } catch {
7768
+ this.emitNotice(
7769
+ "error",
7770
+ "Unable to confirm owned subagent cleanup; session was not replaced. Wait for or inspect remaining subagents, then retry /new.",
7771
+ "new-session-subagent-cleanup",
7772
+ );
7773
+ manager.finishOwnerSubagentShutdown(lease, "release");
7774
+ return false;
7657
7775
  }
7658
- } else {
7659
- await this.sessionManager.flush();
7776
+
7777
+ if (!(await manager.waitForOwnerInFlightDeliveries(ownerId))) {
7778
+ throw new Error("Owned async deliveries did not settle before session replacement.");
7779
+ }
7780
+ if (!options?.drop) await this.sessionManager.flush();
7781
+
7782
+ if (!(await manager.cancelAndSettleOwnerJobs(ownerId))) {
7783
+ throw new Error("Owned async jobs did not settle before session replacement.");
7784
+ }
7785
+
7786
+ this.#disconnectFromAgent();
7787
+ this.#closeAllProviderSessions("new session");
7788
+ this.#rebindProviderSessionState(new Map());
7789
+ this.agent.reset();
7790
+ await this.sessionManager.newSession(options);
7791
+ this.setTodoPhases([]);
7792
+ this.#syncAgentSessionId();
7793
+ this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
7794
+ this.#rekeyHindsightMemoryForCurrentSessionId();
7795
+ this.#resetHindsightConversationTrackingIfHindsight();
7796
+ this.#steeringMessages = [];
7797
+ this.#followUpMessages = [];
7798
+ this.#pendingNextTurnMessages = [];
7799
+ this.#scheduledHiddenNextTurnGeneration = undefined;
7800
+ await this.#initializeNewSessionState(nextDiscoverySessionToolNames, previousSessionFile);
7801
+ if (options?.drop && previousSessionFile) {
7802
+ try {
7803
+ await this.sessionManager.dropSession(previousSessionFile);
7804
+ } catch (err) {
7805
+ logger.error("Failed to delete session during /drop", { err });
7806
+ }
7807
+ }
7808
+ manager.finishOwnerSubagentShutdown(lease, "commit");
7809
+ return true;
7810
+ } catch (error) {
7811
+ manager.finishOwnerSubagentShutdown(
7812
+ lease,
7813
+ this.sessionManager.getSessionId() !== previousSessionIdentity ? "commit" : "release",
7814
+ );
7815
+ throw error;
7660
7816
  }
7661
- await this.sessionManager.newSession(options);
7662
- this.setTodoPhases([]);
7663
- this.#syncAgentSessionId();
7664
- this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
7665
- this.#rekeyHindsightMemoryForCurrentSessionId();
7666
- this.#resetHindsightConversationTrackingIfHindsight();
7667
- this.#steeringMessages = [];
7668
- this.#followUpMessages = [];
7669
- this.#pendingNextTurnMessages = [];
7670
- this.#scheduledHiddenNextTurnGeneration = undefined;
7817
+ }
7671
7818
 
7819
+ async #initializeNewSessionState(
7820
+ nextDiscoverySessionToolNames: string[] | undefined,
7821
+ previousSessionFile: string | undefined,
7822
+ ): Promise<void> {
7672
7823
  const inheritedThinkingLevel = resolveThinkingLevelForModel(this.model, this.#getInheritedThinkingLevel());
7673
7824
  this.#thinkingLevel = inheritedThinkingLevel;
7674
7825
  this.agent.setThinkingLevel(toReasoningEffort(inheritedThinkingLevel));
@@ -7687,15 +7838,11 @@ export class AgentSession {
7687
7838
  this.sessionFile,
7688
7839
  this.#getConfiguredDefaultSelectedMCPToolNames(),
7689
7840
  );
7690
-
7691
7841
  this.#todoReminderCount = 0;
7692
7842
  this.#planReferenceSent = false;
7693
7843
  this.#planReferencePath = "local://PLAN.md";
7694
7844
  this.#reconnectToAgent();
7695
-
7696
7845
  this.#resetIrcRosterDeliveryState();
7697
-
7698
- // Emit session_switch event with reason "new" to hooks
7699
7846
  if (this.#extensionRunner) {
7700
7847
  await this.#extensionRunner.emit({
7701
7848
  type: "session_switch",
@@ -7703,8 +7850,6 @@ export class AgentSession {
7703
7850
  previousSessionFile,
7704
7851
  });
7705
7852
  }
7706
-
7707
- return true;
7708
7853
  }
7709
7854
 
7710
7855
  /**
@@ -10291,11 +10436,13 @@ export class AgentSession {
10291
10436
  sessionId: string | undefined;
10292
10437
  providerSessionState: Map<string, ProviderSessionState>;
10293
10438
  preferWebsockets: boolean | undefined;
10439
+ remoteCompactionFallbackHealth: RemoteCompactionFallbackHealthHooks;
10294
10440
  } {
10295
10441
  return {
10296
10442
  sessionId: this.agent.providerSessionId ?? this.agent.sessionId,
10297
10443
  providerSessionState: this.#providerSessionState,
10298
10444
  preferWebsockets: this.agent.preferWebsockets,
10445
+ remoteCompactionFallbackHealth: this.#remoteCompactionFallbackHealth,
10299
10446
  };
10300
10447
  }
10301
10448
 
package/src/task/index.ts CHANGED
@@ -19,7 +19,7 @@ import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@gajae
19
19
  import type { Model, Usage } from "@gajae-code/ai";
20
20
  import { $env, prompt, Snowflake } from "@gajae-code/utils";
21
21
  import type { ToolSession } from "..";
22
- import { AsyncJobManager } from "../async";
22
+ import { AsyncJobManager, OwnerSubagentShutdownError, type ResumeRunner } from "../async";
23
23
  import { resolveAgentModelPatterns } from "../config/model-resolver";
24
24
  import type { Theme } from "../modes/theme/theme";
25
25
  import planModeSubagentPrompt from "../prompts/system/plan-mode-subagent.md" with { type: "text" };
@@ -547,8 +547,9 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
547
547
  };
548
548
 
549
549
  const maxConcurrency = this.session.settings.get("task.maxConcurrency");
550
+ let resumeRunner: ResumeRunner | undefined;
550
551
  if (typeof manager.setResumeRunner === "function") {
551
- manager.setResumeRunner((_subagentId, message, resumeDescriptor) => {
552
+ resumeRunner = (_subagentId, message, resumeDescriptor) => {
552
553
  const descriptor = isTaskResumeDescriptor(resumeDescriptor?.data) ? resumeDescriptor.data : undefined;
553
554
  if (!descriptor) return undefined;
554
555
  const forkSeeds = descriptor.forkContextSeed
@@ -590,7 +591,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
590
591
  },
591
592
  },
592
593
  );
593
- });
594
+ };
595
+ manager.setResumeRunner(resumeRunner);
594
596
  }
595
597
  const semaphore = new Semaphore(maxConcurrency);
596
598
  const buildForkContextSeedForTask = async (task: TaskItem): Promise<ForkContextSeed | undefined> => {
@@ -770,18 +772,21 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
770
772
  );
771
773
  startedJobs.push({ jobId, taskId: taskItem.id });
772
774
  if (typeof manager.registerResumeDescriptor === "function") {
773
- manager.registerResumeDescriptor({
774
- subagentId: uniqueId,
775
- ownerId: this.session.getAgentId?.() ?? undefined,
776
- data: {
777
- toolCallId: _toolCallId,
778
- params,
779
- task: { ...taskItem, id: uniqueId },
780
- sessionFile: subtaskSessionFile,
781
- forkContextSeed: frozenForkSeed,
782
- agentSource: fallbackAgentSource,
783
- } satisfies TaskResumeDescriptor,
784
- });
775
+ manager.registerResumeDescriptor(
776
+ {
777
+ subagentId: uniqueId,
778
+ ownerId: this.session.getAgentId?.() ?? undefined,
779
+ data: {
780
+ toolCallId: _toolCallId,
781
+ params,
782
+ task: { ...taskItem, id: uniqueId },
783
+ sessionFile: subtaskSessionFile,
784
+ forkContextSeed: frozenForkSeed,
785
+ agentSource: fallbackAgentSource,
786
+ } satisfies TaskResumeDescriptor,
787
+ },
788
+ resumeRunner,
789
+ );
785
790
  }
786
791
  if (typeof manager.registerSubagentRecord === "function") {
787
792
  manager.registerSubagentRecord({
@@ -795,7 +800,12 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
795
800
  });
796
801
  }
797
802
  } catch (error) {
798
- const message = error instanceof Error ? error.message : String(error);
803
+ const message =
804
+ error instanceof OwnerSubagentShutdownError
805
+ ? error.message
806
+ : error instanceof Error
807
+ ? error.message
808
+ : String(error);
799
809
  failedSchedules.push(`${taskItem.id}: ${message}`);
800
810
  const progress = progressByTaskId.get(taskItem.id);
801
811
  if (progress) {