@gajae-code/coding-agent 0.17.6 → 0.17.7

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 (48) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/cli/public-command-errors.d.ts +2 -0
  3. package/dist/types/commands/sdk.d.ts +7 -1
  4. package/dist/types/config/model-profile-ownership.d.ts +74 -0
  5. package/dist/types/config/model-registry.d.ts +2 -1
  6. package/dist/types/config/settings-schema.d.ts +32 -0
  7. package/dist/types/sdk/broker/lifecycle.d.ts +4 -1
  8. package/dist/types/sdk/lifecycle/service.d.ts +2 -0
  9. package/dist/types/sdk/lifecycle-session.d.ts +5 -1
  10. package/dist/types/sdk/session-directory.d.ts +1 -0
  11. package/dist/types/sdk/session-list.d.ts +11 -0
  12. package/dist/types/sdk/startup-capability.d.ts +1 -1
  13. package/dist/types/session/agent-session.d.ts +6 -0
  14. package/dist/types/session/internal/managed-session-scope.d.ts +4 -0
  15. package/dist/types/session/session-manager.d.ts +6 -0
  16. package/dist/types/setup/model-onboarding-guidance.d.ts +18 -0
  17. package/dist/types/task/delegation-hint.d.ts +53 -0
  18. package/examples/README.md +3 -3
  19. package/examples/custom-tools/README.md +1 -1
  20. package/examples/hooks/README.md +1 -1
  21. package/package.json +7 -7
  22. package/scripts/generate-sdk-operation-inventory.ts +6 -0
  23. package/src/cli/config-cli.ts +2 -0
  24. package/src/cli/public-command-errors.ts +6 -0
  25. package/src/commands/sdk.ts +361 -117
  26. package/src/config/model-profile-ownership.ts +303 -0
  27. package/src/config/model-registry.ts +53 -14
  28. package/src/config/settings-schema.ts +81 -0
  29. package/src/internal-urls/docs-index.generated.ts +18 -17
  30. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  31. package/src/modes/controllers/selector-controller.ts +3 -29
  32. package/src/sdk/broker/broker.ts +120 -23
  33. package/src/sdk/broker/lifecycle.ts +89 -13
  34. package/src/sdk/cli/session-cli.ts +45 -9
  35. package/src/sdk/host/control/dispatch.ts +6 -0
  36. package/src/sdk/lifecycle/service.ts +186 -3
  37. package/src/sdk/lifecycle-session.ts +35 -8
  38. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  39. package/src/sdk/protocol/operation-registry.ts +5 -1
  40. package/src/sdk/session-directory.ts +1 -1
  41. package/src/sdk/session-list.ts +47 -0
  42. package/src/sdk/session.ts +290 -114
  43. package/src/sdk/startup-capability.ts +2 -1
  44. package/src/session/agent-session.ts +102 -4
  45. package/src/session/internal/managed-session-scope.ts +81 -17
  46. package/src/session/session-manager.ts +70 -11
  47. package/src/setup/model-onboarding-guidance.ts +26 -0
  48. package/src/task/delegation-hint.ts +578 -0
@@ -23,6 +23,7 @@ import {
23
23
  } from "../sdk/broker/ensure";
24
24
  import { completeBrokerProcess } from "../sdk/broker/internal";
25
25
  import {
26
+ LifecycleReadinessCleanupError,
26
27
  type LifecycleTranscriptEvidence,
27
28
  readSessionLifecycleLaunchRequest,
28
29
  type SessionLifecycleLaunchRequest,
@@ -39,11 +40,16 @@ import { runSdkStderrDrainer } from "../sdk/broker/stderr-drainer";
39
40
  import { renderSdkSearchTable, runSdkSearch, runSdkSessionCli } from "../sdk/cli";
40
41
  import { renderSpawnTable, runSdkSpawn, SdkMasterCliError } from "../sdk/cli/master-cli";
41
42
  import { runSdkGuidesCli } from "../sdk/guides/cli";
42
- import { type CreateLifecycleAgentSessionResult, createLifecycleAgentSession } from "../sdk/lifecycle-session";
43
+ import {
44
+ type CreateLifecycleAgentSessionResult,
45
+ createLifecycleAgentSession,
46
+ type SdkLifecycleStartupOwner,
47
+ } from "../sdk/lifecycle-session";
43
48
  import { listManagedSessionCandidates, resolveManagedSessionScope } from "../sdk/session-directory";
44
49
  import {
45
- normalizeSdkStartupFailure,
50
+ SdkStartupCapability,
46
51
  type SdkStartupFailure,
52
+ type SdkStartupResult,
47
53
  type SdkStartupRollbackResult,
48
54
  SdkStartupRollbackTracker,
49
55
  } from "../sdk/startup-capability";
@@ -487,12 +493,20 @@ export async function runSessionHost(
487
493
  cwd?: string;
488
494
  processIncarnation?: (pid: number) => string | undefined;
489
495
  applyStartupModelProfiles?: typeof applyStartupModelProfiles;
496
+ initTheme?: typeof initTheme;
497
+ createLifecycleAgentSession?: typeof createLifecycleAgentSession;
498
+ writeSessionLifecycleReady?: typeof writeSessionLifecycleReady;
499
+ writeMcpConfig?: (filePath: string, contents: string) => Promise<number>;
490
500
  } = {},
491
501
  ): Promise<void> {
492
502
  const now = timing.now ?? Date.now;
493
503
  const sleep = timing.sleep ?? (async ms => await Bun.sleep(ms));
494
504
  const readIncarnation = timing.processIncarnation ?? processIncarnation;
495
505
  const applyModelProfiles = timing.applyStartupModelProfiles ?? applyStartupModelProfiles;
506
+ const initializeTheme = timing.initTheme ?? initTheme;
507
+ const createLifecycleSession = timing.createLifecycleAgentSession ?? createLifecycleAgentSession;
508
+ const writeLifecycleReady = timing.writeSessionLifecycleReady ?? writeSessionLifecycleReady;
509
+ const writeMcpConfig = timing.writeMcpConfig ?? ((filePath, contents) => Bun.write(filePath, contents));
496
510
  const request = readSessionLifecycleLaunchRequest(process.env.GJC_SDK_LIFECYCLE_REQUEST, now());
497
511
  const agentDir = process.env.GJC_AGENT_DIR;
498
512
  if (!agentDir) throw new Error("GJC_AGENT_DIR is required for sdk session-host-internal.");
@@ -571,117 +585,314 @@ export async function runSessionHost(
571
585
  throw new Error("SDK startup did not complete before readiness cutoff.");
572
586
  }
573
587
 
574
- // Inlined rather than extracted to a helper: TypeScript's definite-assignment
575
- // analysis does not see a `Promise<never>` helper as terminating, so hoisting
576
- // this would make `opened`/`created` "used before assigned" below.
577
- const registrationFailure = async (error: unknown): Promise<SdkStartupFailure> => {
578
- const rollback = new SdkStartupRollbackTracker();
579
- rollback.recordAbsent();
580
- const failure = normalizeSdkStartupFailure("registration", "failed", error);
581
- await writeFailure(failure, rollback.result);
582
- return failure;
588
+ const rollback = new SdkStartupRollbackTracker();
589
+ const capability = new SdkStartupCapability(rollback, request.readiness ?? "immediate", effectMarker);
590
+ const startupOwner: SdkLifecycleStartupOwner = { capability, rollback };
591
+ let startupComplete = false;
592
+ let readinessPublicationCleanupComplete = true;
593
+ let revokePendingReadinessMarker: (() => Promise<boolean>) | undefined;
594
+ let readinessRevocation: Promise<boolean> | undefined;
595
+ let startupInterruption: SdkStartupFailure | undefined;
596
+ const interrupted = Promise.withResolvers<SdkStartupFailure>();
597
+ const startReadinessRevocation = (revoke: () => Promise<boolean>): void => {
598
+ readinessPublicationCleanupComplete = false;
599
+ readinessRevocation ??= Promise.resolve()
600
+ .then(revoke)
601
+ .then(
602
+ revoked => {
603
+ readinessPublicationCleanupComplete = revoked;
604
+ if (revoked) revokePendingReadinessMarker = undefined;
605
+ return revoked;
606
+ },
607
+ () => {
608
+ readinessPublicationCleanupComplete = false;
609
+ return false;
610
+ },
611
+ );
612
+ };
613
+ const interruptStartup = (failure: SdkStartupFailure): void => {
614
+ if (startupInterruption !== undefined) return;
615
+ startupInterruption = failure;
616
+ capability.cancel(failure);
617
+ if (revokePendingReadinessMarker) startReadinessRevocation(revokePendingReadinessMarker);
618
+ interrupted.resolve(failure);
619
+ };
620
+ const cutoffFailure = (): SdkStartupFailure => capability.normalizeFailure("startup", "pending");
621
+ const onStartupSignal = (): void => {
622
+ interruptStartup(capability.normalizeFailure("startup", "failed", "SDK lifecycle host terminated."));
623
+ };
624
+ const removeStartupSignalHandlers = (): void => {
625
+ process.removeListener("SIGTERM", onStartupSignal);
626
+ process.removeListener("SIGINT", onStartupSignal);
627
+ };
628
+ process.once("SIGTERM", onStartupSignal);
629
+ process.once("SIGINT", onStartupSignal);
630
+ void sleep(Math.max(0, request.semanticReadyDeadlineAt - now())).then(
631
+ () => {
632
+ if (!startupComplete) interruptStartup(cutoffFailure());
633
+ },
634
+ error => {
635
+ if (!startupComplete) interruptStartup(capability.normalizeFailure("startup", "failed", error));
636
+ },
637
+ );
638
+ const interruptedStageCleanupGraceMs = 100;
639
+ const throwIfStartupInterrupted = (): void => {
640
+ if (startupInterruption !== undefined) throw startupInterruption;
641
+ if (now() >= request.semanticReadyDeadlineAt) {
642
+ interruptStartup(cutoffFailure());
643
+ throw startupInterruption;
644
+ }
645
+ };
646
+ const beforeCutoff = async <T>(
647
+ stage: () => Promise<T>,
648
+ cleanupLateResult?: (value: T) => Promise<void>,
649
+ options: { processLocalOnly?: boolean } = {},
650
+ ): Promise<T> => {
651
+ throwIfStartupInterrupted();
652
+ const pending = Promise.resolve()
653
+ .then(stage)
654
+ .then(
655
+ value => ({ value }) as const,
656
+ error => ({ error }) as const,
657
+ );
658
+ const result = await Promise.race([
659
+ pending.then(settlement => ({ settlement }) as const),
660
+ interrupted.promise.then(failure => ({ failure }) as const),
661
+ ]);
662
+ const cleanupLateStage = async (lateStage: typeof pending): Promise<void> => {
663
+ if (options.processLocalOnly) return;
664
+ if (!cleanupLateResult) {
665
+ constructionCleanupComplete = false;
666
+ return;
667
+ }
668
+ const cleanup = lateStage
669
+ .then(async late => {
670
+ if ("error" in late) {
671
+ constructionCleanupComplete = false;
672
+ return;
673
+ }
674
+ try {
675
+ await cleanupLateResult(late.value);
676
+ } catch {
677
+ constructionCleanupComplete = false;
678
+ logger.warn(
679
+ "Late lifecycle stage cleanup failed after startup interruption; rollback remains incomplete.",
680
+ );
681
+ }
682
+ })
683
+ .catch(() => {
684
+ constructionCleanupComplete = false;
685
+ });
686
+ const cleanupCompleted = await Promise.race([
687
+ cleanup.then(() => true),
688
+ Bun.sleep(interruptedStageCleanupGraceMs).then(() => false),
689
+ ]);
690
+ if (!cleanupCompleted) constructionCleanupComplete = false;
691
+ };
692
+ if ("failure" in result) {
693
+ await cleanupLateStage(pending);
694
+ throw result.failure;
695
+ }
696
+ const settlement = result.settlement;
697
+ if (startupInterruption !== undefined || now() >= request.semanticReadyDeadlineAt) {
698
+ const failure = startupInterruption ?? cutoffFailure();
699
+ interruptStartup(failure);
700
+ await cleanupLateStage(Promise.resolve(settlement));
701
+ throw failure;
702
+ }
703
+ if ("error" in settlement) throw settlement.error;
704
+ return settlement.value;
705
+ };
706
+ let constructionCleanupComplete = true;
707
+ const runBoundedStartupCleanup = async (cleanup: () => Promise<void>): Promise<boolean> => {
708
+ const completed = await Promise.race([
709
+ Promise.resolve()
710
+ .then(cleanup)
711
+ .then(
712
+ () => true,
713
+ () => false,
714
+ ),
715
+ Bun.sleep(interruptedStageCleanupGraceMs).then(() => false),
716
+ ]);
717
+ if (!completed) constructionCleanupComplete = false;
718
+ return completed;
719
+ };
720
+ const constructionFailure = (error: unknown): SdkStartupFailure => {
721
+ if (error && typeof error === "object" && "phase" in error && "reason" in error && "message" in error)
722
+ return error as SdkStartupFailure;
723
+ return capability.normalizeFailure("registration", "failed", error);
583
724
  };
584
725
 
585
726
  let opened: { parsed: ParsedArgs; sessionManager: SessionManager | undefined };
727
+ let openedSessionManager: SessionManager | undefined;
728
+ let sessionManagerTransferred = false;
586
729
  let created: CreateLifecycleAgentSessionResult;
587
730
  let mcpConfigDirectory: string | undefined;
588
731
  try {
589
732
  let mcpConfigPath: string | undefined;
590
- try {
591
- opened = await openLifecycleSessionManager(request, cwd, agentDir);
592
- if (request.mcpServers && request.mcpServers.length > 0) {
593
- mcpConfigDirectory = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "gjc-acp-mcp-")));
594
- mcpConfigPath = path.join(mcpConfigDirectory, "mcp.json");
595
- await Bun.write(
596
- mcpConfigPath,
597
- JSON.stringify({
598
- mcpServers: Object.fromEntries(
599
- request.mcpServers.map(server => [
600
- server.name,
601
- "url" in server
602
- ? {
603
- type: server.type,
604
- url: server.url,
605
- ...(server.headers ? { headers: server.headers } : {}),
606
- timeout: ACP_MCP_REQUEST_TIMEOUT_MS,
607
- }
608
- : {
609
- type: "stdio",
610
- command: server.command,
611
- args: server.args,
612
- ...(server.env ? { env: server.env } : {}),
613
- noInheritEnv: true,
614
- timeout: ACP_MCP_REQUEST_TIMEOUT_MS,
615
- },
616
- ]),
617
- ),
618
- }),
619
- );
620
- }
621
- } catch (error) {
622
- throw await registrationFailure(error);
733
+ opened = await beforeCutoff(
734
+ () => openLifecycleSessionManager(request, cwd, agentDir),
735
+ async late => {
736
+ try {
737
+ await late.sessionManager?.close();
738
+ } catch (error) {
739
+ constructionCleanupComplete = false;
740
+ throw error;
741
+ }
742
+ },
743
+ );
744
+ openedSessionManager = opened.sessionManager;
745
+ throwIfStartupInterrupted();
746
+ if (request.mcpServers && request.mcpServers.length > 0) {
747
+ const temporaryDirectory = await beforeCutoff(
748
+ () => fs.mkdtemp(path.join(os.tmpdir(), "gjc-acp-mcp-")),
749
+ async late => {
750
+ if (!(await runBoundedStartupCleanup(() => fs.rm(late, { recursive: true, force: true }))))
751
+ throw new Error("Late MCP configuration directory cleanup did not complete.");
752
+ },
753
+ );
754
+ mcpConfigDirectory = temporaryDirectory;
755
+ const canonicalDirectory = await beforeCutoff(
756
+ () => fs.realpath(temporaryDirectory),
757
+ async late => {
758
+ if (!(await runBoundedStartupCleanup(() => fs.rm(late, { recursive: true, force: true }))))
759
+ throw new Error("Late MCP configuration path cleanup did not complete.");
760
+ },
761
+ );
762
+ mcpConfigDirectory = canonicalDirectory;
763
+ const configPath = path.join(canonicalDirectory, "mcp.json");
764
+ mcpConfigPath = configPath;
765
+ const configContents = JSON.stringify({
766
+ mcpServers: Object.fromEntries(
767
+ request.mcpServers.map(server => [
768
+ server.name,
769
+ "url" in server
770
+ ? {
771
+ type: server.type,
772
+ url: server.url,
773
+ ...(server.headers ? { headers: server.headers } : {}),
774
+ timeout: ACP_MCP_REQUEST_TIMEOUT_MS,
775
+ }
776
+ : {
777
+ type: server.type,
778
+ command: server.command,
779
+ args: server.args,
780
+ ...(server.env ? { env: server.env } : {}),
781
+ noInheritEnv: true,
782
+ timeout: ACP_MCP_REQUEST_TIMEOUT_MS,
783
+ },
784
+ ]),
785
+ ),
786
+ });
787
+ await beforeCutoff(
788
+ () => writeMcpConfig(configPath, configContents),
789
+ async () => {
790
+ if (!(await runBoundedStartupCleanup(() => fs.rm(canonicalDirectory, { recursive: true, force: true }))))
791
+ throw new Error("Late MCP configuration write cleanup did not complete.");
792
+ },
793
+ );
794
+ throwIfStartupInterrupted();
623
795
  }
624
796
 
625
- try {
626
- await initTheme(false);
627
- } catch (error) {
628
- throw await registrationFailure(error);
629
- }
797
+ await beforeCutoff(() => initializeTheme(false), undefined, { processLocalOnly: true });
630
798
 
631
799
  // The longer MCP startup ceiling is scoped to ACP lifecycle launches only:
632
800
  // it applies when this request actually carried `mcpServers`. Ordinary
633
801
  // CLI/SDK `mcpConfigPath` consumers keep the manager's short default.
634
802
  //
635
- // This recheck deliberately sits OUTSIDE the registration catch above.
636
- // Inside it, the throw would be caught, reclassified as
637
- // `registration`/`failed`, and written a second time, losing the
638
- // `startup`/`pending` outcome the readiness cutoff is supposed to report.
639
803
  // Session-manager open, MCP config write, and theme initialization already
640
- // consumed part of the budget, so re-read the clock here.
804
+ // consumed part of the budget. Refuse session construction when the remaining
805
+ // time cannot preserve the MCP startup headroom.
641
806
  let mcpStartupTimeoutMs: number | undefined;
642
807
  if (mcpConfigPath !== undefined) {
643
808
  const remaining = request.semanticReadyDeadlineAt - now() - ACP_MCP_STARTUP_HEADROOM_MS;
644
809
  if (remaining <= 0) {
645
- const absent = new SdkStartupRollbackTracker();
646
- absent.recordAbsent();
647
- await writeFailure(
648
- {
649
- phase: "startup",
650
- reason: "pending",
651
- message: "SDK startup did not complete before readiness cutoff.",
652
- },
653
- absent.result,
654
- );
655
- throw new Error("SDK startup did not complete before readiness cutoff.");
810
+ const failure = cutoffFailure();
811
+ interruptStartup(failure);
812
+ throw failure;
656
813
  }
657
814
  mcpStartupTimeoutMs = remaining;
658
815
  }
659
816
 
660
- try {
661
- created = await createLifecycleAgentSession({
662
- cwd,
663
- agentDir,
664
- sessionManager: opened.sessionManager,
665
- ...(mcpConfigPath ? { mcpConfigPath } : {}),
666
- ...(mcpStartupTimeoutMs !== undefined ? { mcpStartupTimeoutMs } : {}),
667
- ...(request.readiness ? { readiness: request.readiness } : {}),
668
- ...(request.modelId ? { modelId: request.modelId } : {}),
669
- lifecycleRequestId: effectMarker,
670
- });
671
- } catch (error) {
672
- throw await registrationFailure(error);
817
+ created = await beforeCutoff(
818
+ async () => {
819
+ sessionManagerTransferred = true;
820
+ const result = await createLifecycleSession(
821
+ {
822
+ cwd,
823
+ agentDir,
824
+ sessionManager: opened.sessionManager,
825
+ ...(mcpConfigPath ? { mcpConfigPath } : {}),
826
+ ...(mcpStartupTimeoutMs !== undefined ? { mcpStartupTimeoutMs } : {}),
827
+ ...(request.readiness ? { readiness: request.readiness } : {}),
828
+ ...(request.modelId ? { modelId: request.modelId } : {}),
829
+ lifecycleRequestId: effectMarker,
830
+ },
831
+ startupOwner,
832
+ );
833
+ if (mcpConfigDirectory) {
834
+ await fs.rm(mcpConfigDirectory, { recursive: true, force: true });
835
+ mcpConfigDirectory = undefined;
836
+ }
837
+ return result;
838
+ },
839
+ async late => {
840
+ if ("failure" in late) return;
841
+ try {
842
+ await late.session.dispose();
843
+ } catch (error) {
844
+ if (!isSessionDisposalIncompleteError(error)) {
845
+ constructionCleanupComplete = false;
846
+ throw error;
847
+ }
848
+ await late.session.awaitDisposeCompletion();
849
+ }
850
+ },
851
+ );
852
+ } catch (error) {
853
+ removeStartupSignalHandlers();
854
+ if (!sessionManagerTransferred && openedSessionManager) {
855
+ const manager = openedSessionManager;
856
+ await runBoundedStartupCleanup(() => manager.close());
857
+ }
858
+ if (mcpConfigDirectory) {
859
+ const directory = mcpConfigDirectory;
860
+ mcpConfigDirectory = undefined;
861
+ await runBoundedStartupCleanup(() => fs.rm(directory, { recursive: true, force: true }));
673
862
  }
674
- } finally {
675
- if (mcpConfigDirectory) await fs.rm(mcpConfigDirectory, { recursive: true, force: true });
863
+ const failure = constructionFailure(error);
864
+ const settled = capability.settleFailure(failure);
865
+ const durableFailure = settled.status === "failed" ? settled.failure : failure;
866
+ if (rollback.generation === undefined && constructionCleanupComplete) rollback.recordAbsent();
867
+ await writeFailure(durableFailure, rollback.result);
868
+ throw error;
676
869
  }
677
- const { parsed } = opened;
678
870
  if ("failure" in created) {
871
+ removeStartupSignalHandlers();
679
872
  created.rollback.recordAbsent();
873
+ capability.settleFailure(created.failure);
680
874
  await writeFailure(created.failure, created.rollback.result);
681
-
682
875
  throw created.failure;
683
876
  }
684
- const { session, capability, rollback, startDeferredMemoryBackend } = created;
877
+ if (created.capability !== capability || created.rollback !== rollback) {
878
+ removeStartupSignalHandlers();
879
+ try {
880
+ await created.session.dispose();
881
+ } catch (error) {
882
+ if (!isSessionDisposalIncompleteError(error)) throw error;
883
+ await created.session.awaitDisposeCompletion();
884
+ }
885
+ const failure = capability.normalizeFailure(
886
+ "registration",
887
+ "failed",
888
+ "Lifecycle startup owner changed during construction.",
889
+ );
890
+ if (rollback.generation === undefined) rollback.recordAbsent();
891
+ await writeFailure(failure, rollback.result);
892
+ throw failure;
893
+ }
894
+ const { parsed } = opened;
895
+ const { session, startDeferredMemoryBackend } = created;
685
896
  let lifecycleTranscriptPath: string | undefined;
686
897
  session.registerToolSessionTransitionCleanup(async () => {
687
898
  await session.sessionManager.ensureOnDisk();
@@ -730,8 +941,15 @@ export async function runSessionHost(
730
941
  let failureRollback: Promise<void> | undefined;
731
942
  const failAfterRollback = (failure: SdkStartupFailure): Promise<void> => {
732
943
  failureRollback ??= (async () => {
944
+ if (readinessRevocation) {
945
+ const revoked = await Promise.race([
946
+ readinessRevocation,
947
+ Bun.sleep(interruptedStageCleanupGraceMs).then(() => false),
948
+ ]);
949
+ if (!revoked) readinessPublicationCleanupComplete = false;
950
+ }
733
951
  const transcript = await disposeAndCapture();
734
- if (rollback.generation === undefined) rollback.recordAbsent();
952
+ if (rollback.generation === undefined && readinessPublicationCleanupComplete) rollback.recordAbsent();
735
953
  await writeFailure(failure, rollback.result, transcript);
736
954
  })();
737
955
  return failureRollback;
@@ -769,36 +987,20 @@ export async function runSessionHost(
769
987
  };
770
988
  let stopping = false;
771
989
  const stop = (reason?: "detached_idle") => {
772
- if (capability.result?.status === "started") {
990
+ if (startupComplete) {
773
991
  if (stopping) return;
774
992
  stopping = true;
775
993
  void exitAfterSessionDisposal(reason);
776
994
  return;
777
995
  }
778
996
  const failure = capability.normalizeFailure("startup", "failed", "SDK lifecycle host terminated.");
779
- capability.cancel();
780
- void failAfterRollback(failure).finally(() => process.exit(0));
781
- };
782
- const cutoffFailure = (): SdkStartupFailure => capability.normalizeFailure("startup", "pending");
783
- const throwIfCutoff = (): void => {
784
- if (now() >= request.semanticReadyDeadlineAt) {
785
- capability.cancel();
786
- throw cutoffFailure();
787
- }
788
- };
789
- const cutoff = sleep(Math.max(0, request.semanticReadyDeadlineAt - now())).then(() => ({ cutoff: true }) as const);
790
- const beforeCutoff = async <T>(stage: Promise<T>): Promise<T> => {
791
- const result = await Promise.race([stage.then(value => ({ cutoff: false, value }) as const), cutoff]);
792
- if (result.cutoff) {
793
- capability.cancel();
794
- throw cutoffFailure();
795
- }
796
- return result.value;
997
+ interruptStartup(failure);
797
998
  };
999
+ const onReadySignal = (): void => stop();
798
1000
 
799
1001
  try {
800
1002
  const startupThinkingLevel = request.modelId ? parseModelString(request.modelId)?.thinkingLevel : undefined;
801
- const modelProfileStartup =
1003
+ await beforeCutoff(() =>
802
1004
  process.env.GJC_SDK_TEST_HANG_MODEL_PROFILE === cwd
803
1005
  ? new Promise<void>(() => {})
804
1006
  : applyModelProfiles({
@@ -809,32 +1011,76 @@ export async function runSessionHost(
809
1011
  startupThinkingLevel,
810
1012
  preferCachedModels: true,
811
1013
  preferCachedDefaultProfile: true,
812
- });
813
- await beforeCutoff(modelProfileStartup);
814
- throwIfCutoff();
815
- await beforeCutoff(
1014
+ }),
1015
+ );
1016
+ await beforeCutoff(() =>
816
1017
  initializeExtensions(session, {
817
1018
  reportSendError: () => {},
818
1019
  reportRuntimeError: () => {},
819
1020
  onShutdown: stop,
820
1021
  }),
821
1022
  );
822
- throwIfCutoff();
1023
+ throwIfStartupInterrupted();
823
1024
  if (session.sessionManager.getSessionId() !== request.sessionId)
824
1025
  throw new Error(
825
1026
  `Lifecycle session id mismatch: expected ${request.sessionId}, got ${session.sessionManager.getSessionId()}.`,
826
1027
  );
827
- const startup = await beforeCutoff(capability.promise);
1028
+ const startup = await beforeCutoff<SdkStartupResult>(() => capability.promise);
828
1029
  if (startup.status !== "started") throw startup.failure;
829
- throwIfCutoff();
1030
+ throwIfStartupInterrupted();
830
1031
  if (process.env.GJC_SDK_TEST_FAIL_AFTER_REGISTRATION === cwd)
831
1032
  throw new Error("Lifecycle test failure after SDK host registration.");
832
1033
 
833
- await session.sessionManager.ensureOnDisk();
834
- throwIfCutoff();
835
-
836
- await writeSessionLifecycleReady(request.stateRoot, request.sessionId, effectMarker);
1034
+ await beforeCutoff(() => session.sessionManager.ensureOnDisk());
1035
+ throwIfStartupInterrupted();
1036
+ let readinessPublished = false;
1037
+ readinessPublicationCleanupComplete = false;
1038
+ const readinessOutcome = await beforeCutoff(
1039
+ async () => {
1040
+ try {
1041
+ await writeLifecycleReady(
1042
+ request.stateRoot,
1043
+ request.sessionId,
1044
+ effectMarker,
1045
+ () => startupInterruption === undefined && now() < request.semanticReadyDeadlineAt,
1046
+ revoke => {
1047
+ revokePendingReadinessMarker = revoke;
1048
+ if (startupInterruption !== undefined) startReadinessRevocation(revoke);
1049
+ else if (now() >= request.semanticReadyDeadlineAt) {
1050
+ interruptStartup(cutoffFailure());
1051
+ }
1052
+ },
1053
+ () => {
1054
+ readinessPublished = true;
1055
+ },
1056
+ );
1057
+ return { published: true } as const;
1058
+ } catch (error) {
1059
+ return { published: false, error } as const;
1060
+ }
1061
+ },
1062
+ async outcome => {
1063
+ readinessPublicationCleanupComplete =
1064
+ !outcome.published && !(outcome.error instanceof LifecycleReadinessCleanupError);
1065
+ },
1066
+ );
1067
+ if (!readinessOutcome.published) {
1068
+ readinessPublicationCleanupComplete = !(readinessOutcome.error instanceof LifecycleReadinessCleanupError);
1069
+ throw readinessOutcome.error;
1070
+ }
1071
+ if (!readinessPublished) {
1072
+ readinessPublicationCleanupComplete = false;
1073
+ throw new Error("Lifecycle readiness publication completed without its commit callback.");
1074
+ }
1075
+ readinessPublicationCleanupComplete = true;
1076
+ startupComplete = true;
1077
+ revokePendingReadinessMarker = undefined;
1078
+ readinessRevocation = undefined;
1079
+ removeStartupSignalHandlers();
1080
+ process.once("SIGTERM", onReadySignal);
1081
+ process.once("SIGINT", onReadySignal);
837
1082
  } catch (error) {
1083
+ removeStartupSignalHandlers();
838
1084
  const failure =
839
1085
  error && typeof error === "object" && "phase" in error && "reason" in error && "message" in error
840
1086
  ? (error as SdkStartupFailure)
@@ -860,8 +1106,6 @@ export async function runSessionHost(
860
1106
  await Bun.sleep(5_000);
861
1107
  }
862
1108
  startMemoryBackendAfterReadiness(startDeferredMemoryBackend);
863
- process.once("SIGTERM", () => stop());
864
- process.once("SIGINT", () => stop());
865
1109
  // Two independent bounds, either of which reaps this detached host through
866
1110
  // the same graceful teardown a SIGTERM would take. The first covers a broker
867
1111
  // that is gone for good; the second covers the opposite case, a perfectly