pilotswarm-sdk 0.4.0 → 0.5.0

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 (164) hide show
  1. package/api/src/http-api-transport.js +4 -4
  2. package/api/src/protocol.js +10 -3
  3. package/dist/agent-loader.d.ts +2 -0
  4. package/dist/agent-loader.d.ts.map +1 -1
  5. package/dist/agent-loader.js +7 -1
  6. package/dist/agent-loader.js.map +1 -1
  7. package/dist/blob-store.d.ts +10 -1
  8. package/dist/blob-store.d.ts.map +1 -1
  9. package/dist/blob-store.js +275 -13
  10. package/dist/blob-store.js.map +1 -1
  11. package/dist/client.d.ts +1 -0
  12. package/dist/client.d.ts.map +1 -1
  13. package/dist/client.js +5 -1
  14. package/dist/client.js.map +1 -1
  15. package/dist/cms-migrations.d.ts.map +1 -1
  16. package/dist/cms-migrations.js +544 -1
  17. package/dist/cms-migrations.js.map +1 -1
  18. package/dist/cms.d.ts +18 -4
  19. package/dist/cms.d.ts.map +1 -1
  20. package/dist/cms.js +62 -3
  21. package/dist/cms.js.map +1 -1
  22. package/dist/fault-injection.d.ts +2 -0
  23. package/dist/fault-injection.d.ts.map +1 -0
  24. package/dist/fault-injection.js +45 -0
  25. package/dist/fault-injection.js.map +1 -0
  26. package/dist/management-client.d.ts +6 -2
  27. package/dist/management-client.d.ts.map +1 -1
  28. package/dist/management-client.js +8 -4
  29. package/dist/management-client.js.map +1 -1
  30. package/dist/orchestration/index.d.ts +3 -3
  31. package/dist/orchestration/index.js +1 -1
  32. package/dist/orchestration/lifecycle.d.ts +9 -2
  33. package/dist/orchestration/lifecycle.d.ts.map +1 -1
  34. package/dist/orchestration/lifecycle.js +21 -55
  35. package/dist/orchestration/lifecycle.js.map +1 -1
  36. package/dist/orchestration/runtime.d.ts +1 -1
  37. package/dist/orchestration/state.d.ts +7 -3
  38. package/dist/orchestration/state.d.ts.map +1 -1
  39. package/dist/orchestration/state.js +13 -4
  40. package/dist/orchestration/state.js.map +1 -1
  41. package/dist/orchestration/turn.d.ts.map +1 -1
  42. package/dist/orchestration/turn.js +98 -66
  43. package/dist/orchestration/turn.js.map +1 -1
  44. package/dist/orchestration-registry.d.ts.map +1 -1
  45. package/dist/orchestration-registry.js +6 -2
  46. package/dist/orchestration-registry.js.map +1 -1
  47. package/dist/orchestration-version.d.ts +1 -1
  48. package/dist/orchestration-version.js +1 -1
  49. package/dist/orchestration.d.ts +2 -2
  50. package/dist/orchestration.js +4 -4
  51. package/dist/orchestration_1_0_46.d.ts +1 -1
  52. package/dist/orchestration_1_0_47.d.ts +1 -1
  53. package/dist/orchestration_1_0_48.d.ts +1 -1
  54. package/dist/orchestration_1_0_49.d.ts +1 -1
  55. package/dist/orchestration_1_0_50.d.ts +1 -1
  56. package/dist/orchestration_1_0_56/agents.d.ts +41 -0
  57. package/dist/orchestration_1_0_56/agents.d.ts.map +1 -0
  58. package/dist/orchestration_1_0_56/agents.js +758 -0
  59. package/dist/orchestration_1_0_56/agents.js.map +1 -0
  60. package/dist/orchestration_1_0_56/index.d.ts +24 -0
  61. package/dist/orchestration_1_0_56/index.d.ts.map +1 -0
  62. package/dist/orchestration_1_0_56/index.js +13 -0
  63. package/dist/orchestration_1_0_56/index.js.map +1 -0
  64. package/dist/orchestration_1_0_56/lifecycle.d.ts +41 -0
  65. package/dist/orchestration_1_0_56/lifecycle.d.ts.map +1 -0
  66. package/dist/orchestration_1_0_56/lifecycle.js +580 -0
  67. package/dist/orchestration_1_0_56/lifecycle.js.map +1 -0
  68. package/dist/orchestration_1_0_56/queue.d.ts +7 -0
  69. package/dist/orchestration_1_0_56/queue.d.ts.map +1 -0
  70. package/dist/orchestration_1_0_56/queue.js +644 -0
  71. package/dist/orchestration_1_0_56/queue.js.map +1 -0
  72. package/dist/orchestration_1_0_56/runtime.d.ts +29 -0
  73. package/dist/orchestration_1_0_56/runtime.d.ts.map +1 -0
  74. package/dist/orchestration_1_0_56/runtime.js +194 -0
  75. package/dist/orchestration_1_0_56/runtime.js.map +1 -0
  76. package/dist/orchestration_1_0_56/state.d.ts +126 -0
  77. package/dist/orchestration_1_0_56/state.d.ts.map +1 -0
  78. package/dist/orchestration_1_0_56/state.js +106 -0
  79. package/dist/orchestration_1_0_56/state.js.map +1 -0
  80. package/dist/orchestration_1_0_56/turn.d.ts +30 -0
  81. package/dist/orchestration_1_0_56/turn.d.ts.map +1 -0
  82. package/dist/orchestration_1_0_56/turn.js +1075 -0
  83. package/dist/orchestration_1_0_56/turn.js.map +1 -0
  84. package/dist/orchestration_1_0_56/utils.d.ts +22 -0
  85. package/dist/orchestration_1_0_56/utils.d.ts.map +1 -0
  86. package/dist/orchestration_1_0_56/utils.js +226 -0
  87. package/dist/orchestration_1_0_56/utils.js.map +1 -0
  88. package/dist/orchestration_1_0_57/agents.d.ts +41 -0
  89. package/dist/orchestration_1_0_57/agents.d.ts.map +1 -0
  90. package/dist/orchestration_1_0_57/agents.js +758 -0
  91. package/dist/orchestration_1_0_57/agents.js.map +1 -0
  92. package/dist/orchestration_1_0_57/index.d.ts +24 -0
  93. package/dist/orchestration_1_0_57/index.d.ts.map +1 -0
  94. package/dist/orchestration_1_0_57/index.js +13 -0
  95. package/dist/orchestration_1_0_57/index.js.map +1 -0
  96. package/dist/orchestration_1_0_57/lifecycle.d.ts +49 -0
  97. package/dist/orchestration_1_0_57/lifecycle.d.ts.map +1 -0
  98. package/dist/orchestration_1_0_57/lifecycle.js +583 -0
  99. package/dist/orchestration_1_0_57/lifecycle.js.map +1 -0
  100. package/dist/orchestration_1_0_57/queue.d.ts +7 -0
  101. package/dist/orchestration_1_0_57/queue.d.ts.map +1 -0
  102. package/dist/orchestration_1_0_57/queue.js +644 -0
  103. package/dist/orchestration_1_0_57/queue.js.map +1 -0
  104. package/dist/orchestration_1_0_57/runtime.d.ts +29 -0
  105. package/dist/orchestration_1_0_57/runtime.d.ts.map +1 -0
  106. package/dist/orchestration_1_0_57/runtime.js +198 -0
  107. package/dist/orchestration_1_0_57/runtime.js.map +1 -0
  108. package/dist/orchestration_1_0_57/state.d.ts +133 -0
  109. package/dist/orchestration_1_0_57/state.d.ts.map +1 -0
  110. package/dist/orchestration_1_0_57/state.js +115 -0
  111. package/dist/orchestration_1_0_57/state.js.map +1 -0
  112. package/dist/orchestration_1_0_57/turn.d.ts +30 -0
  113. package/dist/orchestration_1_0_57/turn.d.ts.map +1 -0
  114. package/dist/orchestration_1_0_57/turn.js +1110 -0
  115. package/dist/orchestration_1_0_57/turn.js.map +1 -0
  116. package/dist/orchestration_1_0_57/utils.d.ts +22 -0
  117. package/dist/orchestration_1_0_57/utils.d.ts.map +1 -0
  118. package/dist/orchestration_1_0_57/utils.js +226 -0
  119. package/dist/orchestration_1_0_57/utils.js.map +1 -0
  120. package/dist/session-lifecycle.d.ts +80 -0
  121. package/dist/session-lifecycle.d.ts.map +1 -0
  122. package/dist/session-lifecycle.js +257 -0
  123. package/dist/session-lifecycle.js.map +1 -0
  124. package/dist/session-manager.d.ts +21 -0
  125. package/dist/session-manager.d.ts.map +1 -1
  126. package/dist/session-manager.js +137 -1
  127. package/dist/session-manager.js.map +1 -1
  128. package/dist/session-proxy.d.ts +6 -1
  129. package/dist/session-proxy.d.ts.map +1 -1
  130. package/dist/session-proxy.js +1123 -953
  131. package/dist/session-proxy.js.map +1 -1
  132. package/dist/session-store.d.ts +48 -3
  133. package/dist/session-store.d.ts.map +1 -1
  134. package/dist/session-store.js +394 -28
  135. package/dist/session-store.js.map +1 -1
  136. package/dist/snapshot-protocol.d.ts +101 -0
  137. package/dist/snapshot-protocol.d.ts.map +1 -0
  138. package/dist/snapshot-protocol.js +120 -0
  139. package/dist/snapshot-protocol.js.map +1 -0
  140. package/dist/types.d.ts +26 -1
  141. package/dist/types.d.ts.map +1 -1
  142. package/dist/types.js.map +1 -1
  143. package/dist/wait-affinity.d.ts +11 -0
  144. package/dist/wait-affinity.d.ts.map +1 -1
  145. package/dist/wait-affinity.js +6 -0
  146. package/dist/wait-affinity.js.map +1 -1
  147. package/dist/web/web-client.d.ts +1 -0
  148. package/dist/web/web-client.d.ts.map +1 -1
  149. package/dist/web/web-client.js +1 -0
  150. package/dist/web/web-client.js.map +1 -1
  151. package/dist/web/web-management-client.d.ts +30 -11
  152. package/dist/web/web-management-client.d.ts.map +1 -1
  153. package/dist/web/web-management-client.js +34 -19
  154. package/dist/web/web-management-client.js.map +1 -1
  155. package/dist/worker.d.ts +16 -1
  156. package/dist/worker.d.ts.map +1 -1
  157. package/dist/worker.js +92 -13
  158. package/dist/worker.js.map +1 -1
  159. package/package.json +7 -4
  160. package/plugins/mgmt/agents/agent-tuner.agent.md +6 -0
  161. package/plugins/mgmt/agents/facts-manager.agent.md +6 -0
  162. package/plugins/mgmt/agents/pilotswarm.agent.md +6 -0
  163. package/plugins/mgmt/agents/resourcemgr.agent.md +6 -0
  164. package/plugins/mgmt/agents/sweeper.agent.md +6 -0
@@ -13,7 +13,10 @@ import { computeCronAtNextFire } from "./cron-at.js";
13
13
  import { SpanStatusCode, trace as otelTrace } from "@opentelemetry/api";
14
14
  import os from "node:os";
15
15
  import fs from "node:fs";
16
+ import path from "node:path";
16
17
  import { randomUUID } from "node:crypto";
18
+ import { attemptStoreRecovery, runTurnCommit, runTurnPreamble } from "./session-lifecycle.js";
19
+ import { supportsVersionedSnapshots, writeTurnSentinel } from "./snapshot-protocol.js";
17
20
  const SYSTEM_AGENT_IDS = new Set(["pilotswarm", "sweeper", "resourcemgr", "facts-manager", "agent-tuner"]);
18
21
  const SESSION_RECOVERY_NOTICE = "[SYSTEM: The runtime recovered this session after the live Copilot session was lost on a worker. " +
19
22
  "Some very recent in-memory state may have been lost. Re-read the visible conversation and continue carefully from the latest durable state.]";
@@ -23,6 +26,8 @@ const LOSSY_SESSION_REPLAY_NOTICE = "[SYSTEM: The runtime is replaying this turn
23
26
  const CORRUPTED_TRANSCRIPT_REPLAY_NOTICE = "[SYSTEM: The runtime recreated this session after the live Copilot transcript became inconsistent. " +
24
27
  "Some recent in-memory work may be missing, and the previous turn may have partially executed. " +
25
28
  "Re-read the visible conversation and continue carefully from the latest durable state.]";
29
+ const REHYDRATED_SESSION_NOTICE = "[SYSTEM: This session was rehydrated on a different worker from its durable snapshot. " +
30
+ "The conversation history is fully preserved. Continue seamlessly.]";
26
31
  function normalizeJsonObject(value) {
27
32
  if (!value || typeof value !== "object" || Array.isArray(value))
28
33
  return null;
@@ -429,6 +434,7 @@ export function createSessionProxy(ctx, sessionId, affinityKey, config) {
429
434
  ...(turnMeta?.clientMessageIds && turnMeta.clientMessageIds.length > 0
430
435
  ? { clientMessageIds: turnMeta.clientMessageIds }
431
436
  : {}),
437
+ ...(turnMeta?.snapshot ? { snapshot: turnMeta.snapshot } : {}),
432
438
  }, affinityKey);
433
439
  },
434
440
  dehydrate(reason, eventData) {
@@ -618,7 +624,7 @@ export function createSessionManagerProxy(ctx) {
618
624
  // ─── Activity Registration ───────────────────────────────────────
619
625
  // Thin dispatchers — each is a one-liner that calls the corresponding
620
626
  // SessionManager or ManagedSession method.
621
- export function registerActivities(runtime, sessionManager, _sessionStore, githubToken, catalog, provider, storeUrl, cmsSchema, clientConfig,
627
+ export function registerActivities(runtime, sessionManager, sessionStore, githubToken, catalog, provider, storeUrl, cmsSchema, clientConfig,
622
628
  /** Loaded system agents — used by resolveAgentConfig activity. */
623
629
  systemAgents,
624
630
  /** Worker-level session policy — used by getWorkerSessionPolicy activity. */
@@ -715,1020 +721,1159 @@ workerNodeId) {
715
721
  let finalTurnResult = null;
716
722
  try {
717
723
  finalTurnResult = await sessionManager.withRunTurnLock(input.sessionId, "runTurn", async () => {
718
- let session = null;
719
- let effectivePrompt = input.prompt;
720
- try {
721
- session = await sessionManager.getOrCreate(input.sessionId, runConfig, {
722
- turnIndex: input.turnIndex,
724
+ // ── Session lifecycle protocol preamble (proposal §3.3) ─────────
725
+ // Only active when the orchestration (1.0.57+) supplied snapshot
726
+ // coordinates AND the store implements the versioned CAS contract.
727
+ // Absent either, this activity behaves exactly as before.
728
+ const lifecycle = input.snapshot && sessionStore && supportsVersionedSnapshots(sessionStore)
729
+ ? {
730
+ store: sessionStore,
731
+ sessionStateDir: sessionManager.getSessionStateDir(),
732
+ sessionId: input.sessionId,
733
+ expectedVersion: input.snapshot.expectedVersion,
734
+ turnKey: input.snapshot.turnKey,
735
+ dropWarmSession: () => sessionManager.dropWarmSession(input.sessionId),
723
736
  trace,
724
- lockHeld: true,
725
- });
726
- }
727
- catch (err) {
728
- const message = err?.message || String(err);
729
- if (isMissingSessionStateErrorMessage(message) || isLiveSessionLostErrorMessage(message)) {
730
- const detail = isMissingSessionStateErrorMessage(message)
731
- ? stripMissingSessionStatePrefix(message)
732
- : message;
733
- trace(`session=${input.sessionId} missing resumable state before turn ${input.turnIndex ?? "unknown"}; ` +
734
- "starting lossy fresh-session replay");
735
- await recordLossyHandoffEvent(catalog, input.sessionId, workerNodeId, {
736
- cause: "missing_resumable_state_before_run_turn",
737
- message: buildLossyReplayMessage(input.sessionId, detail),
738
- detail,
739
- error: message,
740
- recoveryMode: "fresh_session_replay",
741
- nextStep: "replay_pending_turn_with_recreated_copilot_session",
742
- ...(input.turnIndex != null ? { iteration: input.turnIndex } : {}),
743
- }, (failureMessage) => activityCtx.traceInfo(`[runTurn] ${failureMessage}`));
744
- if (catalog) {
745
- await cmsRetryBestEffort(`runTurn.recordEvent system.message-lossy-replay session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [{
746
- eventType: "system.message",
747
- data: {
748
- content: "The runtime is replaying this turn after a worker restart lost the live Copilot session state before durable dehydrate completed. " +
749
- "Some recent work may be missing or partially executed.",
750
- },
751
- }], workerNodeId), (msg) => activityCtx.traceInfo(msg));
752
- }
753
- try {
754
- session = await sessionManager.getOrCreate(input.sessionId, runConfig, {
755
- turnIndex: 0,
756
- trace,
757
- lockHeld: true,
758
- });
759
- }
760
- catch (recoveryErr) {
761
- const recoveryMessage = recoveryErr?.message || String(recoveryErr);
762
- const fatalMessage = isMissingSessionStateErrorMessage(recoveryMessage)
763
- ? buildUnrecoverableSessionLossMessage(input.sessionId, stripMissingSessionStatePrefix(recoveryMessage))
764
- : buildUnrecoverableSessionLossMessage(input.sessionId, recoveryMessage);
765
- trace(`session=${input.sessionId} lossy replay session recreation failed error=${fatalMessage}`);
766
- return await failForMissingState(fatalMessage);
767
- }
768
- effectivePrompt = mergePromptSections([LOSSY_SESSION_REPLAY_NOTICE, input.prompt]) || input.prompt;
769
737
  }
770
- else {
771
- throw err;
738
+ : null;
739
+ let lifecycleBaseVersion = 0;
740
+ let lifecycleRehydrated = false;
741
+ // Protocol-native persistence stats: the legacy dehydrate/hydrate
742
+ // activities no longer run, so the preamble/commit paths feed the
743
+ // same CMS summary + events the Stats pane reads. Best-effort only.
744
+ const recordLifecycleHydration = async (version) => {
745
+ if (!catalog)
746
+ return;
747
+ await cmsRetryBestEffort(`runTurn.lifecycleHydrated session=${input.sessionId}`, async () => {
748
+ await catalog.upsertSessionMetricSummary(input.sessionId, {
749
+ hydrationCountIncrement: 1,
750
+ lastHydratedAt: true,
751
+ });
752
+ await catalog.recordEvents(input.sessionId, [{
753
+ eventType: "session.hydrated",
754
+ data: { version, protocol: "lifecycle" },
755
+ }], workerNodeId);
756
+ }, (msg) => activityCtx.traceInfo(msg));
757
+ };
758
+ if (lifecycle) {
759
+ const pre = await runTurnPreamble(lifecycle);
760
+ if (pre.kind === "already-committed") {
761
+ activityCtx.traceInfo(`[runTurn] session=${input.sessionId} already-committed recovery at v${pre.version}; ` +
762
+ `returning stored result without re-running the turn`);
763
+ await recordLifecycleHydration(pre.version);
764
+ return { ...pre.result, snapshotVersion: pre.version };
765
+ }
766
+ lifecycleBaseVersion = pre.baseVersion;
767
+ lifecycleRehydrated = pre.kind === "hydrated";
768
+ if (lifecycleRehydrated) {
769
+ await recordLifecycleHydration(pre.baseVersion);
770
+ }
771
+ if (pre.kind === "fresh" && pre.lossy && catalog) {
772
+ await cmsRetryBestEffort(`runTurn.recordEvent snapshot-store-empty session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [{
773
+ eventType: "session.snapshot_store_empty",
774
+ data: {
775
+ expectedVersion: input.snapshot.expectedVersion,
776
+ message: "Snapshot store held no data for a session with committed turns; falling back to fresh-session recovery.",
777
+ },
778
+ }], workerNodeId), (msg) => activityCtx.traceInfo(msg));
772
779
  }
773
780
  }
774
- const getInlineClient = async () => {
775
- if (inlineSdkClient)
776
- return inlineSdkClient;
777
- if (inlineSdkClientPromise)
778
- return await inlineSdkClientPromise;
779
- if (!storeUrl)
780
- throw new Error("No storeUrl — cannot create PilotSwarmClient");
781
- inlineSdkClientPromise = (async () => {
782
- const startedClient = new PilotSwarmClient(internalClientConfig());
783
- await startedClient.start();
784
- inlineSdkClient = startedClient;
785
- return startedClient;
786
- })();
781
+ const executeTurnBody = async () => {
782
+ let session = null;
783
+ let effectivePrompt = input.prompt;
787
784
  try {
788
- return await inlineSdkClientPromise;
789
- }
790
- finally {
791
- inlineSdkClientPromise = null;
792
- }
793
- };
794
- const isAutonomousSystemTurn = () => {
795
- if (!SYSTEM_AGENT_IDS.has(runConfig.agentIdentity || ""))
796
- return false;
797
- if (input.bootstrap === true)
798
- return true;
799
- return /^\s*\[SYSTEM:/i.test(String(input.prompt ?? ""));
800
- };
801
- const sanitizeAutonomousSystemSessionFilters = (args) => {
802
- if (!args || !isAutonomousSystemTurn())
803
- return args;
804
- if (!args.owner_query && !args.owner_kind)
805
- return args;
806
- const sanitized = { ...args };
807
- delete sanitized.owner_query;
808
- delete sanitized.owner_kind;
809
- return sanitized;
810
- };
811
- const isReadOnlyTuner = () => runConfig.agentIdentity === "agent-tuner";
812
- const normalizeAgentLookup = (value) => (value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
813
- const resolveAgentConfigInline = (agentName) => {
814
- const agents = [
815
- ...(userAgents ?? []).map(a => ({ ...a, system: false, creatable: true })),
816
- ...(systemAgents ?? []).map(a => ({ ...a, creatable: false })),
817
- ];
818
- let lookupNamespace;
819
- let rawName = agentName;
820
- if (agentName.includes(":")) {
821
- const parts = agentName.split(":");
822
- lookupNamespace = parts[0];
823
- rawName = parts.slice(1).join(":");
785
+ session = await sessionManager.getOrCreate(input.sessionId, runConfig, {
786
+ turnIndex: input.turnIndex,
787
+ trace,
788
+ lockHeld: true,
789
+ });
824
790
  }
825
- const lookup = normalizeAgentLookup(rawName);
826
- const lookupBase = lookup.replace(/agent$/, "");
827
- return agents.find(a => {
828
- if (lookupNamespace && normalizeAgentLookup(a.namespace) !== normalizeAgentLookup(lookupNamespace))
829
- return false;
830
- const candidates = [a.name, a.id, a.title].map(normalizeAgentLookup).filter(Boolean);
831
- return candidates.includes(lookup) || (lookupBase && candidates.includes(lookupBase));
832
- }) ?? null;
833
- };
834
- const loadDirectChildSessions = async () => {
835
- const sdkClient = await getInlineClient();
836
- const sessions = await sdkClient.listSessions();
837
- const directChildren = sessions.filter(s => s.parentSessionId === input.sessionId);
838
- return await Promise.all(directChildren.map(async (child) => {
839
- const info = await sdkClient._getSessionInfo(child.sessionId);
840
- const outcome = catalog ? await catalog.getChildOutcome(child.sessionId).catch(() => null) : null;
841
- const outcomeResult = normalizeJsonObject(outcome?.resultJson?.current);
842
- const contractCurrent = normalizeJsonObject(outcome?.contractJson?.current);
843
- return {
844
- orchId: `session-${child.sessionId}`,
845
- sessionId: child.sessionId,
846
- title: info.title ?? child.title,
847
- status: info.status,
848
- iterations: info.iterations ?? child.iterations ?? 0,
849
- parentSessionId: child.parentSessionId,
850
- isSystem: child.isSystem ?? info.isSystem ?? false,
851
- agentId: child.agentId ?? info.agentId,
852
- result: outcome?.summary ?? (typeof outcomeResult?.summary === "string" ? outcomeResult.summary : info.result),
853
- contract: contractCurrent ?? undefined,
854
- contractStatus: outcome?.contractJson ? "contracted" : undefined,
855
- verdict: outcome?.verdict ?? undefined,
856
- contractViolations: Array.isArray(outcomeResult?.contractViolations) ? outcomeResult.contractViolations : undefined,
857
- error: info.error,
858
- };
859
- }));
860
- };
861
- const resolveManagedChild = async (agentId) => {
862
- const targetOrchId = agentId.startsWith("session-") ? agentId : `session-${agentId}`;
863
- const children = await loadDirectChildSessions();
864
- const child = children.find(entry => entry.orchId === targetOrchId);
865
- if (!child) {
866
- throw new Error(`agent "${targetOrchId}" not found. Known agents: ${children.filter(entry => !entry.isSystem).map(entry => entry.orchId).join(", ") || "none"}`);
791
+ catch (err) {
792
+ const message = err?.message || String(err);
793
+ if (isMissingSessionStateErrorMessage(message) || isLiveSessionLostErrorMessage(message)) {
794
+ const detail = isMissingSessionStateErrorMessage(message)
795
+ ? stripMissingSessionStatePrefix(message)
796
+ : message;
797
+ // ── Lifecycle store recovery BEFORE lossy replay ─────────
798
+ // The store may hold a perfect committed snapshot (e.g. the
799
+ // SDK refused to resume from locally damaged files). Restore
800
+ // it and retry — lossy replay (whose turn-0 reset DELETES
801
+ // the store snapshot) is only reachable when the store is
802
+ // empty, making that delete a no-op.
803
+ if (lifecycle) {
804
+ try {
805
+ const recoveredVersion = await attemptStoreRecovery(lifecycle);
806
+ if (recoveredVersion != null) {
807
+ trace(`session=${input.sessionId} restored committed snapshot v${recoveredVersion} ` +
808
+ `after getOrCreate failure (${detail.slice(0, 120)}); retrying resume`);
809
+ session = await sessionManager.getOrCreate(input.sessionId, runConfig, {
810
+ turnIndex: input.turnIndex,
811
+ trace,
812
+ lockHeld: true,
813
+ });
814
+ lifecycleBaseVersion = recoveredVersion;
815
+ lifecycleRehydrated = true;
816
+ }
817
+ }
818
+ catch (storeRecoveryErr) {
819
+ trace(`session=${input.sessionId} snapshot-store recovery failed: ` +
820
+ `${storeRecoveryErr?.message ?? storeRecoveryErr}; falling back to lossy replay`);
821
+ session = null;
822
+ }
823
+ }
824
+ if (session) {
825
+ // Recovered losslessly — skip the lossy replay machinery.
826
+ }
827
+ else {
828
+ trace(`session=${input.sessionId} missing resumable state before turn ${input.turnIndex ?? "unknown"}; ` +
829
+ "starting lossy fresh-session replay");
830
+ await recordLossyHandoffEvent(catalog, input.sessionId, workerNodeId, {
831
+ cause: "missing_resumable_state_before_run_turn",
832
+ message: buildLossyReplayMessage(input.sessionId, detail),
833
+ detail,
834
+ error: message,
835
+ recoveryMode: "fresh_session_replay",
836
+ nextStep: "replay_pending_turn_with_recreated_copilot_session",
837
+ ...(input.turnIndex != null ? { iteration: input.turnIndex } : {}),
838
+ }, (failureMessage) => activityCtx.traceInfo(`[runTurn] ${failureMessage}`));
839
+ if (catalog) {
840
+ await cmsRetryBestEffort(`runTurn.recordEvent system.message-lossy-replay session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [{
841
+ eventType: "system.message",
842
+ data: {
843
+ content: "The runtime is replaying this turn after a worker restart lost the live Copilot session state before durable dehydrate completed. " +
844
+ "Some recent work may be missing or partially executed.",
845
+ },
846
+ }], workerNodeId), (msg) => activityCtx.traceInfo(msg));
847
+ }
848
+ try {
849
+ session = await sessionManager.getOrCreate(input.sessionId, runConfig, {
850
+ turnIndex: 0,
851
+ trace,
852
+ lockHeld: true,
853
+ });
854
+ }
855
+ catch (recoveryErr) {
856
+ const recoveryMessage = recoveryErr?.message || String(recoveryErr);
857
+ const fatalMessage = isMissingSessionStateErrorMessage(recoveryMessage)
858
+ ? buildUnrecoverableSessionLossMessage(input.sessionId, stripMissingSessionStatePrefix(recoveryMessage))
859
+ : buildUnrecoverableSessionLossMessage(input.sessionId, recoveryMessage);
860
+ trace(`session=${input.sessionId} lossy replay session recreation failed error=${fatalMessage}`);
861
+ return await failForMissingState(fatalMessage);
862
+ }
863
+ effectivePrompt = mergePromptSections([LOSSY_SESSION_REPLAY_NOTICE, input.prompt]) || input.prompt;
864
+ }
865
+ }
866
+ else {
867
+ throw err;
868
+ }
867
869
  }
868
- if (child.isSystem) {
869
- throw new Error(`agent "${targetOrchId}" is a worker-managed system agent and is not a controllable spawned sub-agent`);
870
+ // ── Lifecycle p3: sentinel marks the dir as mid-mutation from here
871
+ // until the post-turn commit clears it. Written after getOrCreate so
872
+ // the turn-0 reset path cannot wipe it, before any body mutation.
873
+ if (lifecycle) {
874
+ writeTurnSentinel(path.join(sessionManager.getSessionStateDir(), input.sessionId), input.snapshot.turnKey);
875
+ if (lifecycleRehydrated) {
876
+ effectivePrompt = mergePromptSections([REHYDRATED_SESSION_NOTICE, effectivePrompt]) || effectivePrompt;
877
+ }
870
878
  }
871
- return child;
872
- };
873
- const controlToolBridge = {
874
- spawnAgent: async (args) => {
879
+ const getInlineClient = async () => {
880
+ if (inlineSdkClient)
881
+ return inlineSdkClient;
882
+ if (inlineSdkClientPromise)
883
+ return await inlineSdkClientPromise;
884
+ if (!storeUrl)
885
+ throw new Error("No storeUrl — cannot create PilotSwarmClient");
886
+ inlineSdkClientPromise = (async () => {
887
+ const startedClient = new PilotSwarmClient(internalClientConfig());
888
+ await startedClient.start();
889
+ inlineSdkClient = startedClient;
890
+ return startedClient;
891
+ })();
875
892
  try {
876
- const childNestingLevel = (input.nestingLevel ?? 0) + 1;
877
- if (childNestingLevel > MAX_NESTING_LEVEL) {
878
- return `[SYSTEM: spawn_agent failed — you are already at nesting level ${input.nestingLevel ?? 0} (max ${MAX_NESTING_LEVEL}). ` +
879
- `Sub-agents at this depth cannot spawn further sub-agents. Handle the task directly instead.]`;
880
- }
881
- const existingChildren = (await loadDirectChildSessions()).filter(child => !child.isSystem);
882
- const activeCount = existingChildren.filter(child => child.status === "running").length;
883
- if (activeCount >= MAX_SUB_AGENTS) {
884
- return `[SYSTEM: spawn_agent failed — you already have ${activeCount} running sub-agents (max ${MAX_SUB_AGENTS}). ` +
885
- `Wait for some to complete before spawning more.]`;
886
- }
887
- let agentTask = args.task || "";
888
- let agentSystemMessage = args.system_message;
889
- let agentToolNames = args.tool_names;
890
- let agentModel = args.model;
891
- let agentReasoningEffort = args.reasoning_effort;
892
- let agentIsSystem = false;
893
- const explicitAgentTitle = typeof args.title === "string" && args.title.trim() ? args.title.trim() : undefined;
894
- let agentTitle = explicitAgentTitle;
895
- let agentTitleIsExplicit = Boolean(explicitAgentTitle);
896
- let agentId;
897
- let agentSplash;
898
- let boundAgentName;
899
- let promptLayeringKind;
900
- let resolvedAgentName = args.agent_name;
901
- const applyAgentDef = (agentDef, useDefinitionDefaults = false) => {
902
- agentTask = useDefinitionDefaults
903
- ? (agentDef.initialPrompt || `You are the ${agentDef.name} agent. Begin your work.`)
904
- : (args.task || agentDef.initialPrompt || `You are the ${agentDef.name} agent. Begin your work.`);
905
- agentSystemMessage = useDefinitionDefaults ? undefined : args.system_message;
906
- agentToolNames = useDefinitionDefaults
907
- ? (agentDef.tools ?? undefined)
908
- : (args.tool_names ?? agentDef.tools ?? undefined);
909
- agentIsSystem = agentDef.system ?? false;
910
- if (!agentTitleIsExplicit)
911
- agentTitle = agentDef.title;
912
- agentId = agentDef.id ?? resolvedAgentName;
913
- agentSplash = agentDef.splash;
914
- boundAgentName = agentDef.name;
915
- promptLayeringKind = agentDef.promptLayerKind
916
- ?? (agentDef.system
917
- ? ((agentDef.namespace || "pilotswarm") === "pilotswarm"
918
- ? "pilotswarm-system-agent"
919
- : "app-system-agent")
920
- : "app-agent");
893
+ return await inlineSdkClientPromise;
894
+ }
895
+ finally {
896
+ inlineSdkClientPromise = null;
897
+ }
898
+ };
899
+ const isAutonomousSystemTurn = () => {
900
+ if (!SYSTEM_AGENT_IDS.has(runConfig.agentIdentity || ""))
901
+ return false;
902
+ if (input.bootstrap === true)
903
+ return true;
904
+ return /^\s*\[SYSTEM:/i.test(String(input.prompt ?? ""));
905
+ };
906
+ const sanitizeAutonomousSystemSessionFilters = (args) => {
907
+ if (!args || !isAutonomousSystemTurn())
908
+ return args;
909
+ if (!args.owner_query && !args.owner_kind)
910
+ return args;
911
+ const sanitized = { ...args };
912
+ delete sanitized.owner_query;
913
+ delete sanitized.owner_kind;
914
+ return sanitized;
915
+ };
916
+ const isReadOnlyTuner = () => runConfig.agentIdentity === "agent-tuner";
917
+ const normalizeAgentLookup = (value) => (value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
918
+ const resolveAgentConfigInline = (agentName) => {
919
+ const agents = [
920
+ ...(userAgents ?? []).map(a => ({ ...a, system: false, creatable: true })),
921
+ ...(systemAgents ?? []).map(a => ({ ...a, creatable: false })),
922
+ ];
923
+ let lookupNamespace;
924
+ let rawName = agentName;
925
+ if (agentName.includes(":")) {
926
+ const parts = agentName.split(":");
927
+ lookupNamespace = parts[0];
928
+ rawName = parts.slice(1).join(":");
929
+ }
930
+ const lookup = normalizeAgentLookup(rawName);
931
+ const lookupBase = lookup.replace(/agent$/, "");
932
+ return agents.find(a => {
933
+ if (lookupNamespace && normalizeAgentLookup(a.namespace) !== normalizeAgentLookup(lookupNamespace))
934
+ return false;
935
+ const candidates = [a.name, a.id, a.title].map(normalizeAgentLookup).filter(Boolean);
936
+ return candidates.includes(lookup) || (lookupBase && candidates.includes(lookupBase));
937
+ }) ?? null;
938
+ };
939
+ const loadDirectChildSessions = async () => {
940
+ const sdkClient = await getInlineClient();
941
+ const sessions = await sdkClient.listSessions();
942
+ const directChildren = sessions.filter(s => s.parentSessionId === input.sessionId);
943
+ return await Promise.all(directChildren.map(async (child) => {
944
+ const info = await sdkClient._getSessionInfo(child.sessionId);
945
+ const outcome = catalog ? await catalog.getChildOutcome(child.sessionId).catch(() => null) : null;
946
+ const outcomeResult = normalizeJsonObject(outcome?.resultJson?.current);
947
+ const contractCurrent = normalizeJsonObject(outcome?.contractJson?.current);
948
+ return {
949
+ orchId: `session-${child.sessionId}`,
950
+ sessionId: child.sessionId,
951
+ title: info.title ?? child.title,
952
+ status: info.status,
953
+ iterations: info.iterations ?? child.iterations ?? 0,
954
+ parentSessionId: child.parentSessionId,
955
+ isSystem: child.isSystem ?? info.isSystem ?? false,
956
+ agentId: child.agentId ?? info.agentId,
957
+ result: outcome?.summary ?? (typeof outcomeResult?.summary === "string" ? outcomeResult.summary : info.result),
958
+ contract: contractCurrent ?? undefined,
959
+ contractStatus: outcome?.contractJson ? "contracted" : undefined,
960
+ verdict: outcome?.verdict ?? undefined,
961
+ contractViolations: Array.isArray(outcomeResult?.contractViolations) ? outcomeResult.contractViolations : undefined,
962
+ error: info.error,
921
963
  };
922
- if (resolvedAgentName) {
923
- const agentDef = resolveAgentConfigInline(resolvedAgentName);
924
- if (!agentDef) {
925
- return `[SYSTEM: spawn_agent failed — agent "${resolvedAgentName}" not found. Use ps_list_agents to see available agents.]`;
964
+ }));
965
+ };
966
+ const resolveManagedChild = async (agentId) => {
967
+ const targetOrchId = agentId.startsWith("session-") ? agentId : `session-${agentId}`;
968
+ const children = await loadDirectChildSessions();
969
+ const child = children.find(entry => entry.orchId === targetOrchId);
970
+ if (!child) {
971
+ throw new Error(`agent "${targetOrchId}" not found. Known agents: ${children.filter(entry => !entry.isSystem).map(entry => entry.orchId).join(", ") || "none"}`);
972
+ }
973
+ if (child.isSystem) {
974
+ throw new Error(`agent "${targetOrchId}" is a worker-managed system agent and is not a controllable spawned sub-agent`);
975
+ }
976
+ return child;
977
+ };
978
+ const controlToolBridge = {
979
+ spawnAgent: async (args) => {
980
+ try {
981
+ const childNestingLevel = (input.nestingLevel ?? 0) + 1;
982
+ if (childNestingLevel > MAX_NESTING_LEVEL) {
983
+ return `[SYSTEM: spawn_agent failed — you are already at nesting level ${input.nestingLevel ?? 0} (max ${MAX_NESTING_LEVEL}). ` +
984
+ `Sub-agents at this depth cannot spawn further sub-agents. Handle the task directly instead.]`;
985
+ }
986
+ const existingChildren = (await loadDirectChildSessions()).filter(child => !child.isSystem);
987
+ const activeCount = existingChildren.filter(child => child.status === "running").length;
988
+ if (activeCount >= MAX_SUB_AGENTS) {
989
+ return `[SYSTEM: spawn_agent failed — you already have ${activeCount} running sub-agents (max ${MAX_SUB_AGENTS}). ` +
990
+ `Wait for some to complete before spawning more.]`;
991
+ }
992
+ let agentTask = args.task || "";
993
+ let agentSystemMessage = args.system_message;
994
+ let agentToolNames = args.tool_names;
995
+ let agentModel = args.model;
996
+ let agentReasoningEffort = args.reasoning_effort;
997
+ let agentIsSystem = false;
998
+ const explicitAgentTitle = typeof args.title === "string" && args.title.trim() ? args.title.trim() : undefined;
999
+ let agentTitle = explicitAgentTitle;
1000
+ let agentTitleIsExplicit = Boolean(explicitAgentTitle);
1001
+ let agentId;
1002
+ let agentSplash;
1003
+ let agentSplashMobile;
1004
+ let boundAgentName;
1005
+ let promptLayeringKind;
1006
+ let resolvedAgentName = args.agent_name;
1007
+ const applyAgentDef = (agentDef, useDefinitionDefaults = false) => {
1008
+ agentTask = useDefinitionDefaults
1009
+ ? (agentDef.initialPrompt || `You are the ${agentDef.name} agent. Begin your work.`)
1010
+ : (args.task || agentDef.initialPrompt || `You are the ${agentDef.name} agent. Begin your work.`);
1011
+ agentSystemMessage = useDefinitionDefaults ? undefined : args.system_message;
1012
+ agentToolNames = useDefinitionDefaults
1013
+ ? (agentDef.tools ?? undefined)
1014
+ : (args.tool_names ?? agentDef.tools ?? undefined);
1015
+ agentIsSystem = agentDef.system ?? false;
1016
+ if (!agentTitleIsExplicit)
1017
+ agentTitle = agentDef.title;
1018
+ agentId = agentDef.id ?? resolvedAgentName;
1019
+ agentSplash = agentDef.splash;
1020
+ agentSplashMobile = agentDef.splashMobile;
1021
+ boundAgentName = agentDef.name;
1022
+ promptLayeringKind = agentDef.promptLayerKind
1023
+ ?? (agentDef.system
1024
+ ? ((agentDef.namespace || "pilotswarm") === "pilotswarm"
1025
+ ? "pilotswarm-system-agent"
1026
+ : "app-system-agent")
1027
+ : "app-agent");
1028
+ };
1029
+ if (resolvedAgentName) {
1030
+ const agentDef = resolveAgentConfigInline(resolvedAgentName);
1031
+ if (!agentDef) {
1032
+ return `[SYSTEM: spawn_agent failed — agent "${resolvedAgentName}" not found. Use ps_list_agents to see available agents.]`;
1033
+ }
1034
+ if (agentDef.system && agentDef.creatable === false) {
1035
+ return `[SYSTEM: spawn_agent failed — agent "${resolvedAgentName}" is a worker-managed system agent and cannot be spawned from a session. ` +
1036
+ `If it is missing, the workers likely need to be restarted.]`;
1037
+ }
1038
+ applyAgentDef(agentDef, resolvedAgentName !== args.agent_name);
926
1039
  }
927
- if (agentDef.system && agentDef.creatable === false) {
928
- return `[SYSTEM: spawn_agent failed — agent "${resolvedAgentName}" is a worker-managed system agent and cannot be spawned from a session. ` +
929
- `If it is missing, the workers likely need to be restarted.]`;
1040
+ if (agentModel && !agentModel.includes(":")) {
1041
+ return `[SYSTEM: spawn_agent failed — model "${agentModel}" is not allowed. ` +
1042
+ `When overriding a sub-agent model, first call list_available_models and then use the exact provider:model value from that list. ` +
1043
+ `If you are unsure, omit model so the sub-agent inherits your current model.]`;
930
1044
  }
931
- applyAgentDef(agentDef, resolvedAgentName !== args.agent_name);
1045
+ // v1.0.49: same-name duplicate spawns are allowed. The
1046
+ // global MAX_SUB_AGENTS cap and per-spawn nesting limit
1047
+ // still apply. The parent is responsible for closing
1048
+ // each instance with complete_agent / cancel_agent /
1049
+ // delete_agent when it no longer needs the child.
1050
+ const { boundAgentName: _parentBoundAgentName, promptLayering: _parentPromptLayering, isCrawler: _parentIsCrawler, isHarvester: _parentIsHarvester, ...parentConfig } = input.config;
1051
+ const childConfig = {
1052
+ ...parentConfig,
1053
+ ...(agentModel ? { model: agentModel } : {}),
1054
+ ...(agentReasoningEffort ? { reasoningEffort: agentReasoningEffort } : {}),
1055
+ ...(agentSystemMessage ? { systemMessage: agentSystemMessage } : {}),
1056
+ ...(boundAgentName ? { boundAgentName } : {}),
1057
+ ...(promptLayeringKind ? { promptLayering: { kind: promptLayeringKind } } : {}),
1058
+ ...(agentToolNames ? { toolNames: agentToolNames } : {}),
1059
+ ...(args.contract ? { childContract: args.contract } : {}),
1060
+ };
1061
+ const parentSystemMsg = typeof childConfig.systemMessage === "string"
1062
+ ? childConfig.systemMessage
1063
+ : childConfig.systemMessage?.content ?? "";
1064
+ const canSpawnMore = childNestingLevel < MAX_NESTING_LEVEL;
1065
+ const subAgentPreamble = `[SUB-AGENT CONTEXT]\n` +
1066
+ `You are a sub-agent spawned by a parent session (ID: session-${input.sessionId}).\n` +
1067
+ `Your nesting level: ${childNestingLevel} (max: ${MAX_NESTING_LEVEL}).\n` +
1068
+ `Your task: "${agentTask.slice(0, 500)}"\n\n` +
1069
+ `Instructions:\n` +
1070
+ `- Focus exclusively on your assigned task.\n` +
1071
+ `- Your final response will be automatically forwarded to the parent agent.\n` +
1072
+ `- Be thorough but concise — the parent will synthesize results from multiple agents.\n` +
1073
+ `- Do NOT ask the user for input — you are autonomous.\n` +
1074
+ `- You are autonomous and goal-driven. If the task implies ongoing monitoring or follow-through until done, keep yourself alive with durable timers until the goal is complete or you can no longer make progress.\n` +
1075
+ `- If it is ambiguous whether the task should become a long-running recurring workflow, report that ambiguity back to the parent instead of guessing or asking the user directly.\n` +
1076
+ `- When your task is complete, provide a clear summary of your findings/results.\n` +
1077
+ `- Prefer using \`store_fact\` for larger structured context handoffs across your session lineage. Put the durable details in facts, then pass fact keys or \`read_facts\` pointers in messages/prompts instead of pasting large context blobs.\n` +
1078
+ `- If you write any files with write_artifact, you MUST also call export_artifact and include the artifact:// link in your response.\n` +
1079
+ `- If you override a sub-agent model, you MUST first call list_available_models in this session and use only an exact provider:model value returned there. ` +
1080
+ `NEVER invent, guess, shorten, or reuse a stale model name.\n` +
1081
+ `- Worker-managed system agents are not valid spawn targets. If you expect one and it is missing, report that the workers likely need to be restarted.\n` +
1082
+ `- For ANY waiting, sleeping, delaying, or scheduling, you MUST use the \`wait\`, \`wait_on_worker\`, \`cron\`, or \`cron_at\` tools. ` +
1083
+ `Use \`wait\` or \`wait_on_worker\` for one-shot delays. Use \`cron\` for fixed recurring intervals and \`cron_at\` for wall-clock schedules. ` +
1084
+ `Do NOT implement wall-clock schedules by waking every N minutes to check the clock; use \`cron_at\` with an explicit IANA timezone instead. ` +
1085
+ `Do NOT burn tokens polling inside one LLM turn; after a brief immediate re-check at most, yield with a durable timer. ` +
1086
+ `NEVER use setTimeout, sleep, setInterval, or any other timing mechanism.\n` +
1087
+ (canSpawnMore
1088
+ ? `- If your parent task explicitly asks you to spawn sub-agents, delegate, fan out, or parallelize work, you SHOULD do so within runtime limits instead of collapsing the task into a direct answer. ` +
1089
+ `If delegation was not explicitly requested, use your judgment and avoid unnecessary fan-out. ` +
1090
+ `You have ${MAX_NESTING_LEVEL - childNestingLevel} level(s) of nesting remaining. After spawning, call wait_for_agents to block until they finish.\n`
1091
+ : `- You CANNOT spawn sub-agents — you are at the maximum nesting depth. Handle everything directly.\n`);
1092
+ childConfig.systemMessage = subAgentPreamble + (parentSystemMsg ? "\n\n" + parentSystemMsg : "");
1093
+ const sdkClient = await getInlineClient();
1094
+ const normalizedModel = sessionManager.normalizeModelRef(childConfig.model);
1095
+ if (normalizedModel)
1096
+ childConfig.model = normalizedModel;
1097
+ const childSession = await sdkClient.createSession({
1098
+ parentSessionId: input.sessionId,
1099
+ nestingLevel: childNestingLevel,
1100
+ model: childConfig.model,
1101
+ reasoningEffort: childConfig.reasoningEffort,
1102
+ systemMessage: childConfig.systemMessage,
1103
+ boundAgentName: childConfig.boundAgentName,
1104
+ promptLayering: childConfig.promptLayering,
1105
+ toolNames: childConfig.toolNames,
1106
+ waitThreshold: childConfig.waitThreshold,
1107
+ agentId,
1108
+ });
1109
+ if (catalog) {
1110
+ const meta = {};
1111
+ if (agentTitle) {
1112
+ meta.title = (agentTitleIsExplicit || agentIsSystem)
1113
+ ? agentTitle
1114
+ : `${agentTitle}: ${childSession.sessionId.slice(0, 8)}`;
1115
+ }
1116
+ if (agentId)
1117
+ meta.agentId = agentId;
1118
+ if (agentSplash)
1119
+ meta.splash = agentSplash;
1120
+ if (agentSplashMobile)
1121
+ meta.splashMobile = agentSplashMobile;
1122
+ if (Object.keys(meta).length > 0) {
1123
+ // Best-effort: child has been created and is about to be
1124
+ // sent its bootstrap. A failed meta update means the row
1125
+ // is missing title/agentId/splash, not that the spawn
1126
+ // failed. Don't escalate to a thrown spawn_agent.
1127
+ const capturedMeta = meta;
1128
+ await cmsRetryBestEffort(`runTurn.spawn.updateSession meta session=${childSession.sessionId}`, () => catalog.updateSession(childSession.sessionId, capturedMeta), (msg) => activityCtx.traceInfo(msg));
1129
+ }
1130
+ const contractJson = buildContractJson(args.contract, input.sessionId, childSession.sessionId);
1131
+ if (contractJson) {
1132
+ await cmsRetryCritical(`runTurn.spawn.upsertChildOutcome contract child=${childSession.sessionId}`, () => catalog.upsertChildOutcome({
1133
+ childSessionId: childSession.sessionId,
1134
+ parentSessionId: input.sessionId,
1135
+ contractJson,
1136
+ }), (msg) => activityCtx.traceInfo(msg));
1137
+ }
1138
+ }
1139
+ await childSession.send(agentTask, { bootstrap: true });
1140
+ if (catalog) {
1141
+ await cmsRetryBestEffort(`runTurn.spawn.recordEvent agent_spawned session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [{
1142
+ eventType: "session.agent_spawned",
1143
+ data: { childSessionId: childSession.sessionId, agentId: agentId || undefined, task: agentTask.slice(0, 500) },
1144
+ }], workerNodeId), (msg) => activityCtx.traceInfo(msg));
1145
+ }
1146
+ const childOrchId = `session-${childSession.sessionId}`;
1147
+ return `[SYSTEM: Sub-agent spawned successfully.\n` +
1148
+ ` Agent ID: ${childOrchId}\n` +
1149
+ ` ${resolvedAgentName ? `Agent: ${resolvedAgentName}\n ` : ``}Task: "${agentTask.slice(0, 200)}"\n` +
1150
+ ` The agent is now running autonomously. Continue your work in this SAME turn and keep following the user's remaining steps. ` +
1151
+ `Do NOT stop just because the child started. If your plan says to pause, call wait or wait_for_agents explicitly. ` +
1152
+ `You can also use check_agents to poll status, ` +
1153
+ `or message_agent to send instructions.]`;
932
1154
  }
933
- if (agentModel && !agentModel.includes(":")) {
934
- return `[SYSTEM: spawn_agent failed — model "${agentModel}" is not allowed. ` +
935
- `When overriding a sub-agent model, first call list_available_models and then use the exact provider:model value from that list. ` +
936
- `If you are unsure, omit model so the sub-agent inherits your current model.]`;
1155
+ catch (err) {
1156
+ return `[SYSTEM: spawn_agent failed: ${err?.message || String(err)}]`;
937
1157
  }
938
- // v1.0.49: same-name duplicate spawns are allowed. The
939
- // global MAX_SUB_AGENTS cap and per-spawn nesting limit
940
- // still apply. The parent is responsible for closing
941
- // each instance with complete_agent / cancel_agent /
942
- // delete_agent when it no longer needs the child.
943
- const { boundAgentName: _parentBoundAgentName, promptLayering: _parentPromptLayering, isCrawler: _parentIsCrawler, isHarvester: _parentIsHarvester, ...parentConfig } = input.config;
944
- const childConfig = {
945
- ...parentConfig,
946
- ...(agentModel ? { model: agentModel } : {}),
947
- ...(agentReasoningEffort ? { reasoningEffort: agentReasoningEffort } : {}),
948
- ...(agentSystemMessage ? { systemMessage: agentSystemMessage } : {}),
949
- ...(boundAgentName ? { boundAgentName } : {}),
950
- ...(promptLayeringKind ? { promptLayering: { kind: promptLayeringKind } } : {}),
951
- ...(agentToolNames ? { toolNames: agentToolNames } : {}),
952
- ...(args.contract ? { childContract: args.contract } : {}),
953
- };
954
- const parentSystemMsg = typeof childConfig.systemMessage === "string"
955
- ? childConfig.systemMessage
956
- : childConfig.systemMessage?.content ?? "";
957
- const canSpawnMore = childNestingLevel < MAX_NESTING_LEVEL;
958
- const subAgentPreamble = `[SUB-AGENT CONTEXT]\n` +
959
- `You are a sub-agent spawned by a parent session (ID: session-${input.sessionId}).\n` +
960
- `Your nesting level: ${childNestingLevel} (max: ${MAX_NESTING_LEVEL}).\n` +
961
- `Your task: "${agentTask.slice(0, 500)}"\n\n` +
962
- `Instructions:\n` +
963
- `- Focus exclusively on your assigned task.\n` +
964
- `- Your final response will be automatically forwarded to the parent agent.\n` +
965
- `- Be thorough but concise — the parent will synthesize results from multiple agents.\n` +
966
- `- Do NOT ask the user for input — you are autonomous.\n` +
967
- `- You are autonomous and goal-driven. If the task implies ongoing monitoring or follow-through until done, keep yourself alive with durable timers until the goal is complete or you can no longer make progress.\n` +
968
- `- If it is ambiguous whether the task should become a long-running recurring workflow, report that ambiguity back to the parent instead of guessing or asking the user directly.\n` +
969
- `- When your task is complete, provide a clear summary of your findings/results.\n` +
970
- `- Prefer using \`store_fact\` for larger structured context handoffs across your session lineage. Put the durable details in facts, then pass fact keys or \`read_facts\` pointers in messages/prompts instead of pasting large context blobs.\n` +
971
- `- If you write any files with write_artifact, you MUST also call export_artifact and include the artifact:// link in your response.\n` +
972
- `- If you override a sub-agent model, you MUST first call list_available_models in this session and use only an exact provider:model value returned there. ` +
973
- `NEVER invent, guess, shorten, or reuse a stale model name.\n` +
974
- `- Worker-managed system agents are not valid spawn targets. If you expect one and it is missing, report that the workers likely need to be restarted.\n` +
975
- `- For ANY waiting, sleeping, delaying, or scheduling, you MUST use the \`wait\`, \`wait_on_worker\`, \`cron\`, or \`cron_at\` tools. ` +
976
- `Use \`wait\` or \`wait_on_worker\` for one-shot delays. Use \`cron\` for fixed recurring intervals and \`cron_at\` for wall-clock schedules. ` +
977
- `Do NOT implement wall-clock schedules by waking every N minutes to check the clock; use \`cron_at\` with an explicit IANA timezone instead. ` +
978
- `Do NOT burn tokens polling inside one LLM turn; after a brief immediate re-check at most, yield with a durable timer. ` +
979
- `NEVER use setTimeout, sleep, setInterval, or any other timing mechanism.\n` +
980
- (canSpawnMore
981
- ? `- If your parent task explicitly asks you to spawn sub-agents, delegate, fan out, or parallelize work, you SHOULD do so within runtime limits instead of collapsing the task into a direct answer. ` +
982
- `If delegation was not explicitly requested, use your judgment and avoid unnecessary fan-out. ` +
983
- `You have ${MAX_NESTING_LEVEL - childNestingLevel} level(s) of nesting remaining. After spawning, call wait_for_agents to block until they finish.\n`
984
- : `- You CANNOT spawn sub-agents — you are at the maximum nesting depth. Handle everything directly.\n`);
985
- childConfig.systemMessage = subAgentPreamble + (parentSystemMsg ? "\n\n" + parentSystemMsg : "");
986
- const sdkClient = await getInlineClient();
987
- const normalizedModel = sessionManager.normalizeModelRef(childConfig.model);
988
- if (normalizedModel)
989
- childConfig.model = normalizedModel;
990
- const childSession = await sdkClient.createSession({
991
- parentSessionId: input.sessionId,
992
- nestingLevel: childNestingLevel,
993
- model: childConfig.model,
994
- reasoningEffort: childConfig.reasoningEffort,
995
- systemMessage: childConfig.systemMessage,
996
- boundAgentName: childConfig.boundAgentName,
997
- promptLayering: childConfig.promptLayering,
998
- toolNames: childConfig.toolNames,
999
- waitThreshold: childConfig.waitThreshold,
1000
- agentId,
1001
- });
1002
- if (catalog) {
1003
- const meta = {};
1004
- if (agentTitle) {
1005
- meta.title = (agentTitleIsExplicit || agentIsSystem)
1006
- ? agentTitle
1007
- : `${agentTitle}: ${childSession.sessionId.slice(0, 8)}`;
1008
- }
1009
- if (agentId)
1010
- meta.agentId = agentId;
1011
- if (agentSplash)
1012
- meta.splash = agentSplash;
1013
- if (Object.keys(meta).length > 0) {
1014
- // Best-effort: child has been created and is about to be
1015
- // sent its bootstrap. A failed meta update means the row
1016
- // is missing title/agentId/splash, not that the spawn
1017
- // failed. Don't escalate to a thrown spawn_agent.
1018
- const capturedMeta = meta;
1019
- await cmsRetryBestEffort(`runTurn.spawn.updateSession meta session=${childSession.sessionId}`, () => catalog.updateSession(childSession.sessionId, capturedMeta), (msg) => activityCtx.traceInfo(msg));
1158
+ },
1159
+ setSessionModel: async (args) => {
1160
+ try {
1161
+ const requestedModel = String(args.model || "").trim();
1162
+ if (!requestedModel)
1163
+ return "[SYSTEM: set_session_model failed: model is required.]";
1164
+ if (!storeUrl)
1165
+ return "[SYSTEM: set_session_model failed: no storeUrl is configured.]";
1166
+ const resolved = sessionManager.resolveModelSwitchConfig(requestedModel, "reasoning_effort" in args ? args.reasoning_effort ?? null : undefined);
1167
+ const sdkClient = new PilotSwarmClient(internalClientConfig());
1168
+ try {
1169
+ await sdkClient.start();
1170
+ await sdkClient.duroxideClient.enqueueEvent(`session-${input.sessionId}`, "messages", JSON.stringify({
1171
+ type: "cmd",
1172
+ cmd: "set_model",
1173
+ id: `set-model-tool-${randomUUID()}`,
1174
+ args: {
1175
+ model: resolved.model,
1176
+ reasoningEffort: resolved.reasoningEffort,
1177
+ source: "tool",
1178
+ },
1179
+ }));
1020
1180
  }
1021
- const contractJson = buildContractJson(args.contract, input.sessionId, childSession.sessionId);
1022
- if (contractJson) {
1023
- await cmsRetryCritical(`runTurn.spawn.upsertChildOutcome contract child=${childSession.sessionId}`, () => catalog.upsertChildOutcome({
1024
- childSessionId: childSession.sessionId,
1025
- parentSessionId: input.sessionId,
1026
- contractJson,
1027
- }), (msg) => activityCtx.traceInfo(msg));
1181
+ finally {
1182
+ await sdkClient.stop().catch(() => { });
1028
1183
  }
1184
+ const effortText = resolved.reasoningEffort ? `:${resolved.reasoningEffort}` : "";
1185
+ return `[SYSTEM: Model switch accepted. Stop this turn now; the runtime will automatically continue on ${resolved.model}${effortText}.]`;
1029
1186
  }
1030
- await childSession.send(agentTask, { bootstrap: true });
1031
- if (catalog) {
1032
- await cmsRetryBestEffort(`runTurn.spawn.recordEvent agent_spawned session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [{
1033
- eventType: "session.agent_spawned",
1034
- data: { childSessionId: childSession.sessionId, agentId: agentId || undefined, task: agentTask.slice(0, 500) },
1035
- }], workerNodeId), (msg) => activityCtx.traceInfo(msg));
1187
+ catch (err) {
1188
+ return `[SYSTEM: set_session_model failed: ${err?.message || String(err)}]`;
1036
1189
  }
1037
- const childOrchId = `session-${childSession.sessionId}`;
1038
- return `[SYSTEM: Sub-agent spawned successfully.\n` +
1039
- ` Agent ID: ${childOrchId}\n` +
1040
- ` ${resolvedAgentName ? `Agent: ${resolvedAgentName}\n ` : ``}Task: "${agentTask.slice(0, 200)}"\n` +
1041
- ` The agent is now running autonomously. Continue your work in this SAME turn and keep following the user's remaining steps. ` +
1042
- `Do NOT stop just because the child started. If your plan says to pause, call wait or wait_for_agents explicitly. ` +
1043
- `You can also use check_agents to poll status, ` +
1044
- `or message_agent to send instructions.]`;
1045
- }
1046
- catch (err) {
1047
- return `[SYSTEM: spawn_agent failed: ${err?.message || String(err)}]`;
1048
- }
1049
- },
1050
- setSessionModel: async (args) => {
1051
- try {
1052
- const requestedModel = String(args.model || "").trim();
1053
- if (!requestedModel)
1054
- return "[SYSTEM: set_session_model failed: model is required.]";
1055
- if (!storeUrl)
1056
- return "[SYSTEM: set_session_model failed: no storeUrl is configured.]";
1057
- const resolved = sessionManager.resolveModelSwitchConfig(requestedModel, "reasoning_effort" in args ? args.reasoning_effort ?? null : undefined);
1058
- const sdkClient = new PilotSwarmClient(internalClientConfig());
1190
+ },
1191
+ messageAgent: async (args) => {
1059
1192
  try {
1060
- await sdkClient.start();
1061
- await sdkClient.duroxideClient.enqueueEvent(`session-${input.sessionId}`, "messages", JSON.stringify({
1062
- type: "cmd",
1063
- cmd: "set_model",
1064
- id: `set-model-tool-${randomUUID()}`,
1065
- args: {
1066
- model: resolved.model,
1067
- reasoningEffort: resolved.reasoningEffort,
1068
- source: "tool",
1069
- },
1070
- }));
1193
+ const child = await resolveManagedChild(args.agent_id);
1194
+ if (catalog && args.contract_patch) {
1195
+ const existing = await catalog.getChildOutcome(child.sessionId);
1196
+ const contractJson = appendContractPatchJson(existing?.contractJson ?? null, args.contract_patch, input.sessionId, child.sessionId);
1197
+ if (contractJson) {
1198
+ await cmsRetryCritical(`runTurn.message.upsertChildOutcome contract child=${child.sessionId}`, () => catalog.upsertChildOutcome({
1199
+ childSessionId: child.sessionId,
1200
+ parentSessionId: input.sessionId,
1201
+ contractJson,
1202
+ }), (msg) => activityCtx.traceInfo(msg));
1203
+ }
1204
+ }
1205
+ const sdkClient = await getInlineClient();
1206
+ await sdkClient._getDuroxideClient().enqueueEvent(child.orchId, "messages", JSON.stringify({ prompt: args.message }));
1207
+ return `[SYSTEM: Message sent to sub-agent ${child.orchId}: "${args.message.slice(0, 200)}". ` +
1208
+ `Continue your work in this SAME turn. If you are waiting on the child, call wait_for_agents explicitly rather than stopping here.]`;
1071
1209
  }
1072
- finally {
1073
- await sdkClient.stop().catch(() => { });
1210
+ catch (err) {
1211
+ return `[SYSTEM: message_agent failed: ${err?.message || String(err)}]`;
1074
1212
  }
1075
- const effortText = resolved.reasoningEffort ? `:${resolved.reasoningEffort}` : "";
1076
- return `[SYSTEM: Model switch accepted. Stop this turn now; the runtime will automatically continue on ${resolved.model}${effortText}.]`;
1077
- }
1078
- catch (err) {
1079
- return `[SYSTEM: set_session_model failed: ${err?.message || String(err)}]`;
1080
- }
1081
- },
1082
- messageAgent: async (args) => {
1083
- try {
1084
- const child = await resolveManagedChild(args.agent_id);
1085
- if (catalog && args.contract_patch) {
1086
- const existing = await catalog.getChildOutcome(child.sessionId);
1087
- const contractJson = appendContractPatchJson(existing?.contractJson ?? null, args.contract_patch, input.sessionId, child.sessionId);
1088
- if (contractJson) {
1089
- await cmsRetryCritical(`runTurn.message.upsertChildOutcome contract child=${child.sessionId}`, () => catalog.upsertChildOutcome({
1090
- childSessionId: child.sessionId,
1091
- parentSessionId: input.sessionId,
1092
- contractJson,
1093
- }), (msg) => activityCtx.traceInfo(msg));
1213
+ },
1214
+ checkAgents: async () => {
1215
+ try {
1216
+ const children = (await loadDirectChildSessions()).filter(child => !child.isSystem);
1217
+ if (children.length === 0) {
1218
+ return `[SYSTEM: No sub-agents have been spawned yet.]`;
1094
1219
  }
1220
+ const statusLines = children.map((agent) => ` - Agent ${agent.orchId}\n` +
1221
+ ` Title: ${agent.title ?? "(untitled)"}\n` +
1222
+ ` Status: ${agent.status}\n` +
1223
+ ` Contract: ${agent.contractStatus ?? "none"}\n` +
1224
+ ` Verdict: ${agent.verdict ?? "none"}\n` +
1225
+ ` Iterations: ${agent.iterations ?? 0}\n` +
1226
+ (Array.isArray(agent.contractViolations) && agent.contractViolations.length > 0
1227
+ ? ` Violations: ${agent.contractViolations.map((v) => v?.code || v?.message || "violation").join(", ")}\n`
1228
+ : "") +
1229
+ ` Output: ${agent.result ?? agent.error ?? "(no output yet)"}`);
1230
+ return `[SYSTEM: Sub-agent status report (${children.length} agents):\n${statusLines.join("\n")}]`;
1095
1231
  }
1096
- const sdkClient = await getInlineClient();
1097
- await sdkClient._getDuroxideClient().enqueueEvent(child.orchId, "messages", JSON.stringify({ prompt: args.message }));
1098
- return `[SYSTEM: Message sent to sub-agent ${child.orchId}: "${args.message.slice(0, 200)}". ` +
1099
- `Continue your work in this SAME turn. If you are waiting on the child, call wait_for_agents explicitly rather than stopping here.]`;
1100
- }
1101
- catch (err) {
1102
- return `[SYSTEM: message_agent failed: ${err?.message || String(err)}]`;
1103
- }
1104
- },
1105
- checkAgents: async () => {
1106
- try {
1232
+ catch (err) {
1233
+ return `[SYSTEM: check_agents failed: ${err?.message || String(err)}]`;
1234
+ }
1235
+ },
1236
+ resolveWaitForAgents: async (agentIds) => {
1107
1237
  const children = (await loadDirectChildSessions()).filter(child => !child.isSystem);
1108
- if (children.length === 0) {
1109
- return `[SYSTEM: No sub-agents have been spawned yet.]`;
1238
+ if (agentIds && agentIds.length > 0) {
1239
+ return await Promise.all(agentIds.map(async (agentId) => (await resolveManagedChild(agentId)).orchId));
1110
1240
  }
1111
- const statusLines = children.map((agent) => ` - Agent ${agent.orchId}\n` +
1112
- ` Title: ${agent.title ?? "(untitled)"}\n` +
1113
- ` Status: ${agent.status}\n` +
1114
- ` Contract: ${agent.contractStatus ?? "none"}\n` +
1115
- ` Verdict: ${agent.verdict ?? "none"}\n` +
1116
- ` Iterations: ${agent.iterations ?? 0}\n` +
1117
- (Array.isArray(agent.contractViolations) && agent.contractViolations.length > 0
1118
- ? ` Violations: ${agent.contractViolations.map((v) => v?.code || v?.message || "violation").join(", ")}\n`
1119
- : "") +
1120
- ` Output: ${agent.result ?? agent.error ?? "(no output yet)"}`);
1121
- return `[SYSTEM: Sub-agent status report (${children.length} agents):\n${statusLines.join("\n")}]`;
1122
- }
1123
- catch (err) {
1124
- return `[SYSTEM: check_agents failed: ${err?.message || String(err)}]`;
1125
- }
1126
- },
1127
- resolveWaitForAgents: async (agentIds) => {
1128
- const children = (await loadDirectChildSessions()).filter(child => !child.isSystem);
1129
- if (agentIds && agentIds.length > 0) {
1130
- return await Promise.all(agentIds.map(async (agentId) => (await resolveManagedChild(agentId)).orchId));
1131
- }
1132
- const running = children.filter(child => child.status === "running").map(child => child.orchId);
1133
- return running.length > 0 ? running : children.map(child => child.orchId);
1134
- },
1135
- listSessions: async (args) => {
1136
- try {
1137
- const sdkClient = await getInlineClient();
1138
- const effectiveArgs = sanitizeAutonomousSystemSessionFilters(args);
1139
- const limit = Math.max(1, Math.min(100, Number(effectiveArgs?.limit) || 50));
1140
- const query = String(effectiveArgs?.query || "").trim().toLowerCase();
1141
- const exactSessionId = String(effectiveArgs?.session_id || "").replace(/^session-/, "");
1142
- const agentId = String(effectiveArgs?.agent_id || "").trim();
1143
- const state = String(effectiveArgs?.state || "").trim().toLowerCase();
1144
- const parentSessionId = String(effectiveArgs?.parent_session_id || "").replace(/^session-/, "");
1145
- const groupFilterRaw = effectiveArgs?.group_id;
1146
- const groupFilter = typeof groupFilterRaw === "string" ? groupFilterRaw.trim() : undefined;
1147
- const includeChildren = effectiveArgs?.include_children === true;
1148
- const updatedSince = Date.parse(String(effectiveArgs?.updated_since || ""));
1149
- const summaryUpdatedSince = Date.parse(String(effectiveArgs?.summary_updated_since || ""));
1150
- const sessions = (await sdkClient.listSessions()).filter((session) => matchesSessionOwnerFilters(session, {
1151
- includeSystem: effectiveArgs?.include_system === true,
1152
- ownerQuery: effectiveArgs?.owner_query,
1153
- ownerKind: effectiveArgs?.owner_kind,
1154
- })).filter((session) => {
1155
- if (!includeChildren && session.parentSessionId)
1156
- return false;
1157
- if (exactSessionId && session.sessionId !== exactSessionId)
1158
- return false;
1159
- if (agentId && session.agentId !== agentId)
1160
- return false;
1161
- if (state && String(session.status || "").toLowerCase() !== state)
1162
- return false;
1163
- if (parentSessionId && session.parentSessionId !== parentSessionId)
1164
- return false;
1165
- if (groupFilter === "null" && session.groupId)
1166
- return false;
1167
- if (groupFilter && groupFilter !== "null" && session.groupId !== groupFilter)
1168
- return false;
1169
- if (Number.isFinite(updatedSince)) {
1170
- const updatedAt = Date.parse(session.updatedAt || session.lastActiveAt || session.createdAt || "");
1171
- if (!Number.isFinite(updatedAt) || updatedAt < updatedSince)
1241
+ const running = children.filter(child => child.status === "running").map(child => child.orchId);
1242
+ return running.length > 0 ? running : children.map(child => child.orchId);
1243
+ },
1244
+ listSessions: async (args) => {
1245
+ try {
1246
+ const sdkClient = await getInlineClient();
1247
+ const effectiveArgs = sanitizeAutonomousSystemSessionFilters(args);
1248
+ const limit = Math.max(1, Math.min(100, Number(effectiveArgs?.limit) || 50));
1249
+ const query = String(effectiveArgs?.query || "").trim().toLowerCase();
1250
+ const exactSessionId = String(effectiveArgs?.session_id || "").replace(/^session-/, "");
1251
+ const agentId = String(effectiveArgs?.agent_id || "").trim();
1252
+ const state = String(effectiveArgs?.state || "").trim().toLowerCase();
1253
+ const parentSessionId = String(effectiveArgs?.parent_session_id || "").replace(/^session-/, "");
1254
+ const groupFilterRaw = effectiveArgs?.group_id;
1255
+ const groupFilter = typeof groupFilterRaw === "string" ? groupFilterRaw.trim() : undefined;
1256
+ const includeChildren = effectiveArgs?.include_children === true;
1257
+ const updatedSince = Date.parse(String(effectiveArgs?.updated_since || ""));
1258
+ const summaryUpdatedSince = Date.parse(String(effectiveArgs?.summary_updated_since || ""));
1259
+ const sessions = (await sdkClient.listSessions()).filter((session) => matchesSessionOwnerFilters(session, {
1260
+ includeSystem: effectiveArgs?.include_system === true,
1261
+ ownerQuery: effectiveArgs?.owner_query,
1262
+ ownerKind: effectiveArgs?.owner_kind,
1263
+ })).filter((session) => {
1264
+ if (!includeChildren && session.parentSessionId)
1172
1265
  return false;
1173
- }
1174
- if (Number.isFinite(summaryUpdatedSince)) {
1175
- const summaryUpdatedAt = Date.parse(session.summaryUpdatedAt || "");
1176
- if (!Number.isFinite(summaryUpdatedAt) || summaryUpdatedAt < summaryUpdatedSince)
1266
+ if (exactSessionId && session.sessionId !== exactSessionId)
1177
1267
  return false;
1178
- }
1179
- if (query) {
1180
- const haystack = [
1181
- session.sessionId,
1182
- session.title,
1183
- session.agentId,
1184
- session.shortSummary,
1185
- session.summaryState?.intent,
1186
- session.summaryState?.summary,
1187
- formatSessionOwnerLabel(session),
1188
- ].map((part) => String(part || "").toLowerCase()).join(" ");
1189
- if (!haystack.includes(query))
1268
+ if (agentId && session.agentId !== agentId)
1269
+ return false;
1270
+ if (state && String(session.status || "").toLowerCase() !== state)
1271
+ return false;
1272
+ if (parentSessionId && session.parentSessionId !== parentSessionId)
1273
+ return false;
1274
+ if (groupFilter === "null" && session.groupId)
1190
1275
  return false;
1276
+ if (groupFilter && groupFilter !== "null" && session.groupId !== groupFilter)
1277
+ return false;
1278
+ if (Number.isFinite(updatedSince)) {
1279
+ const updatedAt = Date.parse(session.updatedAt || session.lastActiveAt || session.createdAt || "");
1280
+ if (!Number.isFinite(updatedAt) || updatedAt < updatedSince)
1281
+ return false;
1282
+ }
1283
+ if (Number.isFinite(summaryUpdatedSince)) {
1284
+ const summaryUpdatedAt = Date.parse(session.summaryUpdatedAt || "");
1285
+ if (!Number.isFinite(summaryUpdatedAt) || summaryUpdatedAt < summaryUpdatedSince)
1286
+ return false;
1287
+ }
1288
+ if (query) {
1289
+ const haystack = [
1290
+ session.sessionId,
1291
+ session.title,
1292
+ session.agentId,
1293
+ session.shortSummary,
1294
+ session.summaryState?.intent,
1295
+ session.summaryState?.summary,
1296
+ formatSessionOwnerLabel(session),
1297
+ ].map((part) => String(part || "").toLowerCase()).join(" ");
1298
+ if (!haystack.includes(query))
1299
+ return false;
1300
+ }
1301
+ return true;
1302
+ }).slice(0, limit);
1303
+ if (sessions.length === 0) {
1304
+ return "[SYSTEM: Active sessions (0). No sessions matched the requested filters.]";
1191
1305
  }
1192
- return true;
1193
- }).slice(0, limit);
1194
- if (sessions.length === 0) {
1195
- return "[SYSTEM: Active sessions (0). No sessions matched the requested filters.]";
1306
+ const lines = sessions.map((s) => ` - ${s.sessionId}${s.sessionId === input.sessionId ? " (this session)" : ""}\n` +
1307
+ ` Title: ${s.title ?? "(untitled)"}\n` +
1308
+ ` Owner: ${formatSessionOwnerLabel(s)}\n` +
1309
+ ` Agent: ${s.agentId ?? "generic"}\n` +
1310
+ ` Group: ${s.groupId ?? "none"}\n` +
1311
+ ` Summary: ${s.shortSummary ?? s.summaryState?.summary ?? "(no summary)"}\n` +
1312
+ ` Summary Updated: ${s.summaryUpdatedAt ? new Date(s.summaryUpdatedAt).toISOString() : "never"}\n` +
1313
+ ` Status: ${s.status}, Iterations: ${s.iterations ?? 0}\n` +
1314
+ ` Parent: ${s.parentSessionId ?? "none"}`);
1315
+ return `[SYSTEM: Active sessions (${sessions.length}):\n${lines.join("\n")}]`;
1196
1316
  }
1197
- const lines = sessions.map((s) => ` - ${s.sessionId}${s.sessionId === input.sessionId ? " (this session)" : ""}\n` +
1198
- ` Title: ${s.title ?? "(untitled)"}\n` +
1199
- ` Owner: ${formatSessionOwnerLabel(s)}\n` +
1200
- ` Agent: ${s.agentId ?? "generic"}\n` +
1201
- ` Group: ${s.groupId ?? "none"}\n` +
1202
- ` Summary: ${s.shortSummary ?? s.summaryState?.summary ?? "(no summary)"}\n` +
1203
- ` Summary Updated: ${s.summaryUpdatedAt ? new Date(s.summaryUpdatedAt).toISOString() : "never"}\n` +
1204
- ` Status: ${s.status}, Iterations: ${s.iterations ?? 0}\n` +
1205
- ` Parent: ${s.parentSessionId ?? "none"}`);
1206
- return `[SYSTEM: Active sessions (${sessions.length}):\n${lines.join("\n")}]`;
1207
- }
1208
- catch (err) {
1209
- return `[SYSTEM: list_sessions failed: ${err?.message || String(err)}]`;
1210
- }
1211
- },
1212
- updateSessionSummary: async (args) => {
1213
- try {
1214
- if (isReadOnlyTuner())
1215
- return `[SYSTEM: update_session_summary is disabled for read-only agent-tuner sessions.]`;
1216
- if (!catalog)
1217
- return `[SYSTEM: update_session_summary failed: CMS catalog is unavailable.]`;
1218
- const hasSummaryState = Object.prototype.hasOwnProperty.call(args ?? {}, "summary_state") && args.summary_state !== undefined && args.summary_state !== null;
1219
- const hasTitle = Object.prototype.hasOwnProperty.call(args ?? {}, "title");
1220
- if (!hasSummaryState && !hasTitle) {
1221
- return `[SYSTEM: update_session_summary failed: summary_state or title is required.]`;
1317
+ catch (err) {
1318
+ return `[SYSTEM: list_sessions failed: ${err?.message || String(err)}]`;
1222
1319
  }
1223
- let normalizedSummary;
1224
- if (hasSummaryState) {
1225
- const summaryResult = normalizeSummaryState(args.summary_state);
1226
- if (!summaryResult.ok)
1227
- return `[SYSTEM: update_session_summary failed: ${summaryResult.message}]`;
1228
- normalizedSummary = summaryResult;
1320
+ },
1321
+ updateSessionSummary: async (args) => {
1322
+ try {
1323
+ if (isReadOnlyTuner())
1324
+ return `[SYSTEM: update_session_summary is disabled for read-only agent-tuner sessions.]`;
1325
+ if (!catalog)
1326
+ return `[SYSTEM: update_session_summary failed: CMS catalog is unavailable.]`;
1327
+ const hasSummaryState = Object.prototype.hasOwnProperty.call(args ?? {}, "summary_state") && args.summary_state !== undefined && args.summary_state !== null;
1328
+ const hasTitle = Object.prototype.hasOwnProperty.call(args ?? {}, "title");
1329
+ if (!hasSummaryState && !hasTitle) {
1330
+ return `[SYSTEM: update_session_summary failed: summary_state or title is required.]`;
1331
+ }
1332
+ let normalizedSummary;
1333
+ if (hasSummaryState) {
1334
+ const summaryResult = normalizeSummaryState(args.summary_state);
1335
+ if (!summaryResult.ok)
1336
+ return `[SYSTEM: update_session_summary failed: ${summaryResult.message}]`;
1337
+ normalizedSummary = summaryResult;
1338
+ }
1339
+ const titleUpdate = hasTitle
1340
+ ? await prepareStickySessionTitleUpdate(catalog, input.sessionId, args.title)
1341
+ : undefined;
1342
+ if (titleUpdate && !titleUpdate.ok)
1343
+ return `[SYSTEM: update_session_summary failed: ${titleUpdate.message}]`;
1344
+ const updated = [];
1345
+ if (normalizedSummary) {
1346
+ await catalog.updateSessionSummary(input.sessionId, normalizedSummary.summaryState, args.short_summary);
1347
+ updated.push("summary");
1348
+ }
1349
+ if (titleUpdate?.ok) {
1350
+ await catalog.updateSession(input.sessionId, titleUpdate.updates);
1351
+ updated.push("title");
1352
+ }
1353
+ return `[SYSTEM: Session ${updated.join(" and ")} updated.]`;
1229
1354
  }
1230
- const titleUpdate = hasTitle
1231
- ? await prepareStickySessionTitleUpdate(catalog, input.sessionId, args.title)
1232
- : undefined;
1233
- if (titleUpdate && !titleUpdate.ok)
1234
- return `[SYSTEM: update_session_summary failed: ${titleUpdate.message}]`;
1235
- const updated = [];
1236
- if (normalizedSummary) {
1237
- await catalog.updateSessionSummary(input.sessionId, normalizedSummary.summaryState, args.short_summary);
1238
- updated.push("summary");
1355
+ catch (err) {
1356
+ return `[SYSTEM: update_session_summary failed: ${err?.message || String(err)}]`;
1239
1357
  }
1240
- if (titleUpdate?.ok) {
1241
- await catalog.updateSession(input.sessionId, titleUpdate.updates);
1242
- updated.push("title");
1358
+ },
1359
+ sendSessionMessage: async (args) => {
1360
+ try {
1361
+ if (isReadOnlyTuner())
1362
+ return `[SYSTEM: send_session_message is disabled for read-only agent-tuner sessions.]`;
1363
+ const targetSessionId = String(args.session_id || "").replace(/^session-/, "");
1364
+ if (!targetSessionId)
1365
+ return `[SYSTEM: send_session_message failed: session_id is required.]`;
1366
+ if (targetSessionId === input.sessionId)
1367
+ return `[SYSTEM: send_session_message failed: target session is this session.]`;
1368
+ if (!args.subject?.trim() || !args.body?.trim())
1369
+ return `[SYSTEM: send_session_message failed: subject and body are required.]`;
1370
+ if (args.body.length > 8192)
1371
+ return `[SYSTEM: send_session_message failed: body exceeds 8 KB.]`;
1372
+ if (!catalog)
1373
+ return `[SYSTEM: send_session_message failed: CMS catalog is unavailable.]`;
1374
+ const sdkClient = await getInlineClient();
1375
+ const allowedReasons = new Set(["help", "guidance", "fact-request", "status-request", "handoff"]);
1376
+ const reason = allowedReasons.has(String(args.reason || "")) ? args.reason : undefined;
1377
+ const { requestId } = await sendInternalSessionMessage({
1378
+ catalog,
1379
+ duroxideClient: sdkClient._getDuroxideClient(),
1380
+ }, {
1381
+ fromSessionId: input.sessionId,
1382
+ toSessionId: targetSessionId,
1383
+ subject: args.subject,
1384
+ body: args.body,
1385
+ reason,
1386
+ expectsResponse: args.expects_response === true,
1387
+ expiresAt: args.expires_at,
1388
+ });
1389
+ return `[SYSTEM: Cross-session message queued. Request ID: ${requestId}. Target: session-${targetSessionId}.]`;
1243
1390
  }
1244
- return `[SYSTEM: Session ${updated.join(" and ")} updated.]`;
1245
- }
1246
- catch (err) {
1247
- return `[SYSTEM: update_session_summary failed: ${err?.message || String(err)}]`;
1248
- }
1249
- },
1250
- sendSessionMessage: async (args) => {
1251
- try {
1252
- if (isReadOnlyTuner())
1253
- return `[SYSTEM: send_session_message is disabled for read-only agent-tuner sessions.]`;
1254
- const targetSessionId = String(args.session_id || "").replace(/^session-/, "");
1255
- if (!targetSessionId)
1256
- return `[SYSTEM: send_session_message failed: session_id is required.]`;
1257
- if (targetSessionId === input.sessionId)
1258
- return `[SYSTEM: send_session_message failed: target session is this session.]`;
1259
- if (!args.subject?.trim() || !args.body?.trim())
1260
- return `[SYSTEM: send_session_message failed: subject and body are required.]`;
1261
- if (args.body.length > 8192)
1262
- return `[SYSTEM: send_session_message failed: body exceeds 8 KB.]`;
1263
- if (!catalog)
1264
- return `[SYSTEM: send_session_message failed: CMS catalog is unavailable.]`;
1265
- const sdkClient = await getInlineClient();
1266
- const allowedReasons = new Set(["help", "guidance", "fact-request", "status-request", "handoff"]);
1267
- const reason = allowedReasons.has(String(args.reason || "")) ? args.reason : undefined;
1268
- const { requestId } = await sendInternalSessionMessage({
1269
- catalog,
1270
- duroxideClient: sdkClient._getDuroxideClient(),
1271
- }, {
1272
- fromSessionId: input.sessionId,
1273
- toSessionId: targetSessionId,
1274
- subject: args.subject,
1275
- body: args.body,
1276
- reason,
1277
- expectsResponse: args.expects_response === true,
1278
- expiresAt: args.expires_at,
1279
- });
1280
- return `[SYSTEM: Cross-session message queued. Request ID: ${requestId}. Target: session-${targetSessionId}.]`;
1281
- }
1282
- catch (err) {
1283
- return `[SYSTEM: send_session_message failed: ${err?.message || String(err)}]`;
1284
- }
1285
- },
1286
- replySessionMessage: async (args) => {
1287
- try {
1288
- if (isReadOnlyTuner())
1289
- return `[SYSTEM: reply_session_message is disabled for read-only agent-tuner sessions.]`;
1290
- const targetSessionId = String(args.session_id || "").replace(/^session-/, "");
1291
- if (!targetSessionId || !args.request_id || !args.body?.trim())
1292
- return `[SYSTEM: reply_session_message failed: request_id, session_id, and body are required.]`;
1293
- if (!catalog)
1294
- return `[SYSTEM: reply_session_message failed: CMS catalog is unavailable.]`;
1295
- const sdkClient = await getInlineClient();
1296
- const allowedVerdicts = new Set(["answered", "declined", "blocked", "stale"]);
1297
- const verdict = allowedVerdicts.has(String(args.verdict || "")) ? args.verdict : "answered";
1298
- await replyInternalSessionMessage({
1299
- catalog,
1300
- duroxideClient: sdkClient._getDuroxideClient(),
1301
- }, {
1302
- requestId: args.request_id,
1303
- fromSessionId: input.sessionId,
1304
- toSessionId: targetSessionId,
1305
- verdict,
1306
- body: args.body,
1307
- });
1308
- return `[SYSTEM: Cross-session reply queued for request ${args.request_id}. Target: session-${targetSessionId}.]`;
1309
- }
1310
- catch (err) {
1311
- return `[SYSTEM: reply_session_message failed: ${err?.message || String(err)}]`;
1312
- }
1313
- },
1314
- completeAgent: async (args) => {
1315
- try {
1316
- const child = await resolveManagedChild(args.agent_id);
1317
- let builtOutcome = null;
1318
- if (catalog) {
1319
- const existing = await catalog.getChildOutcome(child.sessionId).catch(() => null);
1320
- const hasExistingResult = Boolean(normalizeJsonObject(existing?.resultJson?.current));
1321
- if (args.result || (existing?.contractJson && !hasExistingResult)) {
1322
- builtOutcome = buildResultJson(args.result, child, existing?.contractJson ?? null, existing?.resultJson ?? null, "success", "completed_without_result");
1391
+ catch (err) {
1392
+ return `[SYSTEM: send_session_message failed: ${err?.message || String(err)}]`;
1393
+ }
1394
+ },
1395
+ replySessionMessage: async (args) => {
1396
+ try {
1397
+ if (isReadOnlyTuner())
1398
+ return `[SYSTEM: reply_session_message is disabled for read-only agent-tuner sessions.]`;
1399
+ const targetSessionId = String(args.session_id || "").replace(/^session-/, "");
1400
+ if (!targetSessionId || !args.request_id || !args.body?.trim())
1401
+ return `[SYSTEM: reply_session_message failed: request_id, session_id, and body are required.]`;
1402
+ if (!catalog)
1403
+ return `[SYSTEM: reply_session_message failed: CMS catalog is unavailable.]`;
1404
+ const sdkClient = await getInlineClient();
1405
+ const allowedVerdicts = new Set(["answered", "declined", "blocked", "stale"]);
1406
+ const verdict = allowedVerdicts.has(String(args.verdict || "")) ? args.verdict : "answered";
1407
+ await replyInternalSessionMessage({
1408
+ catalog,
1409
+ duroxideClient: sdkClient._getDuroxideClient(),
1410
+ }, {
1411
+ requestId: args.request_id,
1412
+ fromSessionId: input.sessionId,
1413
+ toSessionId: targetSessionId,
1414
+ verdict,
1415
+ body: args.body,
1416
+ });
1417
+ return `[SYSTEM: Cross-session reply queued for request ${args.request_id}. Target: session-${targetSessionId}.]`;
1418
+ }
1419
+ catch (err) {
1420
+ return `[SYSTEM: reply_session_message failed: ${err?.message || String(err)}]`;
1421
+ }
1422
+ },
1423
+ completeAgent: async (args) => {
1424
+ try {
1425
+ const child = await resolveManagedChild(args.agent_id);
1426
+ let builtOutcome = null;
1427
+ if (catalog) {
1428
+ const existing = await catalog.getChildOutcome(child.sessionId).catch(() => null);
1429
+ const hasExistingResult = Boolean(normalizeJsonObject(existing?.resultJson?.current));
1430
+ if (args.result || (existing?.contractJson && !hasExistingResult)) {
1431
+ builtOutcome = buildResultJson(args.result, child, existing?.contractJson ?? null, existing?.resultJson ?? null, "success", "completed_without_result");
1432
+ }
1433
+ if (builtOutcome?.strictBlocked) {
1434
+ return `[SYSTEM: complete_agent blocked by strict contract validation for ${child.orchId}: ${builtOutcome.violations.map(v => v.message || v.code).join("; ")}]`;
1435
+ }
1323
1436
  }
1324
- if (builtOutcome?.strictBlocked) {
1325
- return `[SYSTEM: complete_agent blocked by strict contract validation for ${child.orchId}: ${builtOutcome.violations.map(v => v.message || v.code).join("; ")}]`;
1437
+ if (catalog && builtOutcome) {
1438
+ await cmsRetryCritical(`runTurn.complete.upsertChildOutcome result child=${child.sessionId}`, () => catalog.upsertChildOutcome({
1439
+ childSessionId: child.sessionId,
1440
+ parentSessionId: input.sessionId,
1441
+ resultJson: builtOutcome.resultJson,
1442
+ verdict: builtOutcome.verdict,
1443
+ summary: builtOutcome.summary,
1444
+ completedAt: new Date(),
1445
+ }), (msg) => activityCtx.traceInfo(msg));
1326
1446
  }
1447
+ const sdkClient = await getInlineClient();
1448
+ const cmdId = `done-inline-${Date.now()}`;
1449
+ await sdkClient._getDuroxideClient().enqueueEvent(child.orchId, "messages", JSON.stringify({ type: "cmd", cmd: "done", id: cmdId, args: { reason: "Completed by parent" } }));
1450
+ return `[SYSTEM: Graceful completion requested for sub-agent ${child.orchId}. ` +
1451
+ `Use check_agents or wait_for_agents to observe final completion.]`;
1327
1452
  }
1328
- if (catalog && builtOutcome) {
1329
- await cmsRetryCritical(`runTurn.complete.upsertChildOutcome result child=${child.sessionId}`, () => catalog.upsertChildOutcome({
1330
- childSessionId: child.sessionId,
1331
- parentSessionId: input.sessionId,
1332
- resultJson: builtOutcome.resultJson,
1333
- verdict: builtOutcome.verdict,
1334
- summary: builtOutcome.summary,
1335
- completedAt: new Date(),
1336
- }), (msg) => activityCtx.traceInfo(msg));
1453
+ catch (err) {
1454
+ return `[SYSTEM: complete_agent failed: ${err?.message || String(err)}]`;
1337
1455
  }
1338
- const sdkClient = await getInlineClient();
1339
- const cmdId = `done-inline-${Date.now()}`;
1340
- await sdkClient._getDuroxideClient().enqueueEvent(child.orchId, "messages", JSON.stringify({ type: "cmd", cmd: "done", id: cmdId, args: { reason: "Completed by parent" } }));
1341
- return `[SYSTEM: Graceful completion requested for sub-agent ${child.orchId}. ` +
1342
- `Use check_agents or wait_for_agents to observe final completion.]`;
1343
- }
1344
- catch (err) {
1345
- return `[SYSTEM: complete_agent failed: ${err?.message || String(err)}]`;
1346
- }
1347
- },
1348
- cancelAgent: async (args) => {
1349
- try {
1350
- const child = await resolveManagedChild(args.agent_id);
1351
- let builtOutcome = null;
1352
- if (catalog) {
1353
- const existing = await catalog.getChildOutcome(child.sessionId).catch(() => null);
1354
- const hasExistingResult = Boolean(normalizeJsonObject(existing?.resultJson?.current));
1355
- if (args.partial_result || (existing?.contractJson && !hasExistingResult)) {
1356
- builtOutcome = buildResultJson(args.partial_result, child, existing?.contractJson ?? null, existing?.resultJson ?? null, "cancelled", "completed_without_result");
1456
+ },
1457
+ cancelAgent: async (args) => {
1458
+ try {
1459
+ const child = await resolveManagedChild(args.agent_id);
1460
+ let builtOutcome = null;
1461
+ if (catalog) {
1462
+ const existing = await catalog.getChildOutcome(child.sessionId).catch(() => null);
1463
+ const hasExistingResult = Boolean(normalizeJsonObject(existing?.resultJson?.current));
1464
+ if (args.partial_result || (existing?.contractJson && !hasExistingResult)) {
1465
+ builtOutcome = buildResultJson(args.partial_result, child, existing?.contractJson ?? null, existing?.resultJson ?? null, "cancelled", "completed_without_result");
1466
+ }
1467
+ }
1468
+ if (catalog && builtOutcome) {
1469
+ await cmsRetryCritical(`runTurn.cancel.upsertChildOutcome partial child=${child.sessionId}`, () => catalog.upsertChildOutcome({
1470
+ childSessionId: child.sessionId,
1471
+ parentSessionId: input.sessionId,
1472
+ resultJson: builtOutcome.resultJson,
1473
+ verdict: builtOutcome.verdict,
1474
+ summary: builtOutcome.summary,
1475
+ completedAt: new Date(),
1476
+ }), (msg) => activityCtx.traceInfo(msg));
1357
1477
  }
1478
+ const sdkClient = await getInlineClient();
1479
+ const cmdId = `cancel-inline-${Date.now()}`;
1480
+ await sdkClient._getDuroxideClient().enqueueEvent(child.orchId, "messages", JSON.stringify({ type: "cmd", cmd: "cancel", id: cmdId, args: { reason: args.reason ?? "Cancelled by parent" } }));
1481
+ return `[SYSTEM: Graceful cancellation requested for sub-agent ${child.orchId}. ` +
1482
+ `Use check_agents or wait_for_agents to observe final termination.${args.reason ? ` Reason: ${args.reason}` : ""}]`;
1358
1483
  }
1359
- if (catalog && builtOutcome) {
1360
- await cmsRetryCritical(`runTurn.cancel.upsertChildOutcome partial child=${child.sessionId}`, () => catalog.upsertChildOutcome({
1361
- childSessionId: child.sessionId,
1362
- parentSessionId: input.sessionId,
1363
- resultJson: builtOutcome.resultJson,
1364
- verdict: builtOutcome.verdict,
1365
- summary: builtOutcome.summary,
1366
- completedAt: new Date(),
1367
- }), (msg) => activityCtx.traceInfo(msg));
1484
+ catch (err) {
1485
+ return `[SYSTEM: cancel_agent failed: ${err?.message || String(err)}]`;
1368
1486
  }
1369
- const sdkClient = await getInlineClient();
1370
- const cmdId = `cancel-inline-${Date.now()}`;
1371
- await sdkClient._getDuroxideClient().enqueueEvent(child.orchId, "messages", JSON.stringify({ type: "cmd", cmd: "cancel", id: cmdId, args: { reason: args.reason ?? "Cancelled by parent" } }));
1372
- return `[SYSTEM: Graceful cancellation requested for sub-agent ${child.orchId}. ` +
1373
- `Use check_agents or wait_for_agents to observe final termination.${args.reason ? ` Reason: ${args.reason}` : ""}]`;
1374
- }
1375
- catch (err) {
1376
- return `[SYSTEM: cancel_agent failed: ${err?.message || String(err)}]`;
1377
- }
1378
- },
1379
- deleteAgent: async (args) => {
1380
- try {
1381
- const child = await resolveManagedChild(args.agent_id);
1382
- const sdkClient = await getInlineClient();
1383
- if (child.status === "completed" || child.status === "failed" || child.status === "cancelled") {
1384
- await sdkClient.deleteSession(child.sessionId);
1385
- return `[SYSTEM: Sub-agent ${child.orchId} has been deleted.${args.reason ? ` Reason: ${args.reason}` : ""}]`;
1487
+ },
1488
+ deleteAgent: async (args) => {
1489
+ try {
1490
+ const child = await resolveManagedChild(args.agent_id);
1491
+ const sdkClient = await getInlineClient();
1492
+ if (child.status === "completed" || child.status === "failed" || child.status === "cancelled") {
1493
+ await sdkClient.deleteSession(child.sessionId);
1494
+ return `[SYSTEM: Sub-agent ${child.orchId} has been deleted.${args.reason ? ` Reason: ${args.reason}` : ""}]`;
1495
+ }
1496
+ const cmdId = `delete-inline-${Date.now()}`;
1497
+ await sdkClient._getDuroxideClient().enqueueEvent(child.orchId, "messages", JSON.stringify({ type: "cmd", cmd: "delete", id: cmdId, args: { reason: args.reason ?? "Deleted by parent" } }));
1498
+ return `[SYSTEM: Graceful deletion requested for sub-agent ${child.orchId}. ` +
1499
+ `It will cancel its descendants first and then delete itself.${args.reason ? ` Reason: ${args.reason}` : ""}]`;
1386
1500
  }
1387
- const cmdId = `delete-inline-${Date.now()}`;
1388
- await sdkClient._getDuroxideClient().enqueueEvent(child.orchId, "messages", JSON.stringify({ type: "cmd", cmd: "delete", id: cmdId, args: { reason: args.reason ?? "Deleted by parent" } }));
1389
- return `[SYSTEM: Graceful deletion requested for sub-agent ${child.orchId}. ` +
1390
- `It will cancel its descendants first and then delete itself.${args.reason ? ` Reason: ${args.reason}` : ""}]`;
1391
- }
1392
- catch (err) {
1393
- return `[SYSTEM: delete_agent failed: ${err?.message || String(err)}]`;
1501
+ catch (err) {
1502
+ return `[SYSTEM: delete_agent failed: ${err?.message || String(err)}]`;
1503
+ }
1504
+ },
1505
+ };
1506
+ // Cooperative cancellation: poll for lock steal
1507
+ let cancelled = false;
1508
+ cancelPoll = setInterval(() => {
1509
+ if (activityCtx.isCancelled()) {
1510
+ cancelled = true;
1511
+ session?.abort?.();
1512
+ if (cancelPoll)
1513
+ clearInterval(cancelPoll);
1394
1514
  }
1395
- },
1396
- };
1397
- // Cooperative cancellation: poll for lock steal
1398
- let cancelled = false;
1399
- cancelPoll = setInterval(() => {
1400
- if (activityCtx.isCancelled()) {
1401
- cancelled = true;
1402
- session?.abort?.();
1403
- if (cancelPoll)
1404
- clearInterval(cancelPoll);
1405
- }
1406
- }, 2_000);
1407
- // Build onEvent callback: write each non-ephemeral event to CMS as it fires.
1408
- // We track every in-flight CMS recordEvents promise so we can flush them
1409
- // before posting `session.turn_completed`. Without this barrier the
1410
- // turn_completed insert can race ahead of the SDK's `assistant.message`
1411
- // insert and CMS will assign the smaller `seq` to turn_completed,
1412
- // breaking event-ordering invariants downstream (see cms-seq-nodemap).
1413
- const pendingEventWrites = [];
1414
- const trackEventWrite = (promise) => {
1415
- if (!promise || typeof promise.then !== "function")
1416
- return;
1417
- pendingEventWrites.push(promise.catch(() => { }));
1418
- };
1419
- const EPHEMERAL_TYPES = new Set([
1420
- "assistant.message_delta",
1421
- "assistant.streaming_delta",
1422
- "assistant.reasoning_delta",
1423
- "user.message", // Already recorded explicitly above — skip the SDK's duplicate
1424
- ]);
1425
- const onEvent = catalog
1426
- ? (event) => {
1427
- if (EPHEMERAL_TYPES.has(event.eventType))
1515
+ }, 2_000);
1516
+ // Build onEvent callback: write each non-ephemeral event to CMS as it fires.
1517
+ // We track every in-flight CMS recordEvents promise so we can flush them
1518
+ // before posting `session.turn_completed`. Without this barrier the
1519
+ // turn_completed insert can race ahead of the SDK's `assistant.message`
1520
+ // insert and CMS will assign the smaller `seq` to turn_completed,
1521
+ // breaking event-ordering invariants downstream (see cms-seq-nodemap).
1522
+ const pendingEventWrites = [];
1523
+ const trackEventWrite = (promise) => {
1524
+ if (!promise || typeof promise.then !== "function")
1428
1525
  return;
1429
- const persistedEvent = summarizeSdkSystemPromptEchoEvent(event);
1430
- if (!persistedEvent)
1431
- return;
1432
- if (event.eventType === "session.wait_started") {
1433
- const data = (event.data ?? {});
1434
- void cmsRetryBestEffort(`runTurn.onEvent updateSession state=waiting session=${input.sessionId}`, () => catalog.updateSession(input.sessionId, {
1435
- state: "waiting",
1436
- waitReason: data.reason ?? null,
1437
- lastActiveAt: new Date(),
1438
- }), (msg) => activityCtx.traceInfo(msg));
1439
- }
1440
- else if (event.eventType === "session.input_required_started") {
1441
- const data = (event.data ?? {});
1442
- void cmsRetryBestEffort(`runTurn.onEvent updateSession state=input_required session=${input.sessionId}`, () => catalog.updateSession(input.sessionId, {
1443
- state: "input_required",
1444
- waitReason: data.question ?? null,
1445
- lastActiveAt: new Date(),
1446
- }), (msg) => activityCtx.traceInfo(msg));
1447
- }
1448
- if (event.eventType === "assistant.usage") {
1449
- const usageUpsert = buildUsageSummaryUpsert(event.data);
1450
- if (usageUpsert) {
1451
- turnTelemetry.tokensInput += usageUpsert.tokensInputIncrement ?? 0;
1452
- turnTelemetry.tokensOutput += usageUpsert.tokensOutputIncrement ?? 0;
1453
- turnTelemetry.tokensCacheRead += usageUpsert.tokensCacheReadIncrement ?? 0;
1454
- turnTelemetry.tokensCacheWrite += usageUpsert.tokensCacheWriteIncrement ?? 0;
1526
+ pendingEventWrites.push(promise.catch(() => { }));
1527
+ };
1528
+ const EPHEMERAL_TYPES = new Set([
1529
+ "assistant.message_delta",
1530
+ "assistant.streaming_delta",
1531
+ "assistant.reasoning_delta",
1532
+ "user.message", // Already recorded explicitly above — skip the SDK's duplicate
1533
+ ]);
1534
+ const onEvent = catalog
1535
+ ? (event) => {
1536
+ if (EPHEMERAL_TYPES.has(event.eventType))
1537
+ return;
1538
+ const persistedEvent = summarizeSdkSystemPromptEchoEvent(event);
1539
+ if (!persistedEvent)
1540
+ return;
1541
+ if (event.eventType === "session.wait_started") {
1542
+ const data = (event.data ?? {});
1543
+ void cmsRetryBestEffort(`runTurn.onEvent updateSession state=waiting session=${input.sessionId}`, () => catalog.updateSession(input.sessionId, {
1544
+ state: "waiting",
1545
+ waitReason: data.reason ?? null,
1546
+ lastActiveAt: new Date(),
1547
+ }), (msg) => activityCtx.traceInfo(msg));
1455
1548
  }
1549
+ else if (event.eventType === "session.input_required_started") {
1550
+ const data = (event.data ?? {});
1551
+ void cmsRetryBestEffort(`runTurn.onEvent updateSession state=input_required session=${input.sessionId}`, () => catalog.updateSession(input.sessionId, {
1552
+ state: "input_required",
1553
+ waitReason: data.question ?? null,
1554
+ lastActiveAt: new Date(),
1555
+ }), (msg) => activityCtx.traceInfo(msg));
1556
+ }
1557
+ if (event.eventType === "assistant.usage") {
1558
+ const usageUpsert = buildUsageSummaryUpsert(event.data);
1559
+ if (usageUpsert) {
1560
+ turnTelemetry.tokensInput += usageUpsert.tokensInputIncrement ?? 0;
1561
+ turnTelemetry.tokensOutput += usageUpsert.tokensOutputIncrement ?? 0;
1562
+ turnTelemetry.tokensCacheRead += usageUpsert.tokensCacheReadIncrement ?? 0;
1563
+ turnTelemetry.tokensCacheWrite += usageUpsert.tokensCacheWriteIncrement ?? 0;
1564
+ }
1565
+ }
1566
+ else if (event.eventType === "tool.execution_start") {
1567
+ turnTelemetry.toolCalls += 1;
1568
+ const eventData = normalizeEventData(event.data);
1569
+ const toolName = typeof eventData?.toolName === "string"
1570
+ ? eventData.toolName
1571
+ : typeof eventData?.name === "string"
1572
+ ? eventData.name
1573
+ : undefined;
1574
+ if (toolName)
1575
+ turnTelemetry.toolNames.add(toolName);
1576
+ }
1577
+ else if (event.eventType === "tool.execution_complete" && isFailureToolCompletion(event.data)) {
1578
+ turnTelemetry.toolErrors += 1;
1579
+ }
1580
+ // Best-effort with one transient retry. trackEventWrite tracks
1581
+ // the wrapped promise so the post-turn barrier waits for the
1582
+ // retry to settle before emitting turn_completed.
1583
+ const writePromise = cmsRetryBestEffort(`runTurn.onEvent recordEvent ${persistedEvent.eventType} session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [persistedEvent], workerNodeId), (msg) => activityCtx.traceInfo(msg));
1584
+ trackEventWrite(writePromise);
1456
1585
  }
1457
- else if (event.eventType === "tool.execution_start") {
1458
- turnTelemetry.toolCalls += 1;
1459
- const eventData = normalizeEventData(event.data);
1460
- const toolName = typeof eventData?.toolName === "string"
1461
- ? eventData.toolName
1462
- : typeof eventData?.name === "string"
1463
- ? eventData.name
1464
- : undefined;
1465
- if (toolName)
1466
- turnTelemetry.toolNames.add(toolName);
1467
- }
1468
- else if (event.eventType === "tool.execution_complete" && isFailureToolCompletion(event.data)) {
1469
- turnTelemetry.toolErrors += 1;
1586
+ : undefined;
1587
+ // Record the user prompt as a CMS event before running the turn.
1588
+ // Skip internal timer continuation prompts — they're system-generated, not user input.
1589
+ const isTimerPrompt = /^The \d+ second wait is now complete\./i.test(input.prompt);
1590
+ const isRetryAttempt = (input.retryCount ?? 0) > 0;
1591
+ if (catalog && input.config.turnSystemPrompt && !isRetryAttempt) {
1592
+ const persistedSystemPrompt = decorateRehydrationSystemPrompt(input.config.turnSystemPrompt, workerNodeId);
1593
+ void cmsRetryBestEffort(`runTurn.recordEvent system-prompt session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [{
1594
+ eventType: "system.message",
1595
+ data: { content: persistedSystemPrompt },
1596
+ }], workerNodeId), (msg) => activityCtx.traceInfo(msg));
1597
+ }
1598
+ if (catalog && !isTimerPrompt && !input.bootstrap && !isRetryAttempt) {
1599
+ const promptEventType = isInternalSystemPrompt(input.prompt) ? "system.message" : "user.message";
1600
+ // v1.0.47: when the orchestration tagged the turn with one or
1601
+ // more clientMessageIds (the UI-generated identities of the
1602
+ // contributing local outbox items), persist them on the
1603
+ // durable user.message event so the client can ack/cancel by
1604
+ // exact id rather than text match.
1605
+ const incomingClientMessageIds = Array.isArray(input.clientMessageIds)
1606
+ ? input.clientMessageIds.filter((id) => typeof id === "string" && id)
1607
+ : [];
1608
+ const eventData = { content: input.prompt };
1609
+ if (incomingClientMessageIds.length > 0) {
1610
+ eventData.clientMessageIds = incomingClientMessageIds;
1470
1611
  }
1471
- // Best-effort with one transient retry. trackEventWrite tracks
1472
- // the wrapped promise so the post-turn barrier waits for the
1473
- // retry to settle before emitting turn_completed.
1474
- const writePromise = cmsRetryBestEffort(`runTurn.onEvent recordEvent ${persistedEvent.eventType} session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [persistedEvent], workerNodeId), (msg) => activityCtx.traceInfo(msg));
1475
- trackEventWrite(writePromise);
1612
+ const capturedPromptEventType = promptEventType;
1613
+ void cmsRetryBestEffort(`runTurn.recordEvent ${capturedPromptEventType} session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [{
1614
+ eventType: capturedPromptEventType,
1615
+ data: eventData,
1616
+ }], workerNodeId), (msg) => activityCtx.traceInfo(msg));
1476
1617
  }
1477
- : undefined;
1478
- // Record the user prompt as a CMS event before running the turn.
1479
- // Skip internal timer continuation prompts — they're system-generated, not user input.
1480
- const isTimerPrompt = /^The \d+ second wait is now complete\./i.test(input.prompt);
1481
- const isRetryAttempt = (input.retryCount ?? 0) > 0;
1482
- if (catalog && input.config.turnSystemPrompt && !isRetryAttempt) {
1483
- const persistedSystemPrompt = decorateRehydrationSystemPrompt(input.config.turnSystemPrompt, workerNodeId);
1484
- void cmsRetryBestEffort(`runTurn.recordEvent system-prompt session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [{
1485
- eventType: "system.message",
1486
- data: { content: persistedSystemPrompt },
1487
- }], workerNodeId), (msg) => activityCtx.traceInfo(msg));
1488
- }
1489
- if (catalog && !isTimerPrompt && !input.bootstrap && !isRetryAttempt) {
1490
- const promptEventType = isInternalSystemPrompt(input.prompt) ? "system.message" : "user.message";
1491
- // v1.0.47: when the orchestration tagged the turn with one or
1492
- // more clientMessageIds (the UI-generated identities of the
1493
- // contributing local outbox items), persist them on the
1494
- // durable user.message event so the client can ack/cancel by
1495
- // exact id rather than text match.
1496
- const incomingClientMessageIds = Array.isArray(input.clientMessageIds)
1497
- ? input.clientMessageIds.filter((id) => typeof id === "string" && id)
1498
- : [];
1499
- const eventData = { content: input.prompt };
1500
- if (incomingClientMessageIds.length > 0) {
1501
- eventData.clientMessageIds = incomingClientMessageIds;
1618
+ // Mark session as "running" in CMS before the turn, and publish the
1619
+ // in-flight turn index so stopSessionTurn() can address the
1620
+ // turn-scoped stop queue (stopTurn.<turnIndex>).
1621
+ if (catalog) {
1622
+ await cmsRetryBestEffort(`runTurn.preTurn updateSession state=running session=${input.sessionId}`, () => catalog.updateSession(input.sessionId, {
1623
+ state: "running",
1624
+ lastActiveAt: new Date(),
1625
+ }), (msg) => activityCtx.traceInfo(msg));
1626
+ await cmsRetryBestEffort(`runTurn.preTurn setActiveTurnIndex session=${input.sessionId} turn=${input.turnIndex ?? 0}`, () => catalog.setActiveTurnIndex(input.sessionId, input.turnIndex ?? 0), (msg) => activityCtx.traceInfo(msg));
1502
1627
  }
1503
- const capturedPromptEventType = promptEventType;
1504
- void cmsRetryBestEffort(`runTurn.recordEvent ${capturedPromptEventType} session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [{
1505
- eventType: capturedPromptEventType,
1506
- data: eventData,
1507
- }], workerNodeId), (msg) => activityCtx.traceInfo(msg));
1508
- }
1509
- // Mark session as "running" in CMS before the turn, and publish the
1510
- // in-flight turn index so stopSessionTurn() can address the
1511
- // turn-scoped stop queue (stopTurn.<turnIndex>).
1512
- if (catalog) {
1513
- await cmsRetryBestEffort(`runTurn.preTurn updateSession state=running session=${input.sessionId}`, () => catalog.updateSession(input.sessionId, {
1514
- state: "running",
1515
- lastActiveAt: new Date(),
1516
- }), (msg) => activityCtx.traceInfo(msg));
1517
- await cmsRetryBestEffort(`runTurn.preTurn setActiveTurnIndex session=${input.sessionId} turn=${input.turnIndex ?? 0}`, () => catalog.setActiveTurnIndex(input.sessionId, input.turnIndex ?? 0), (msg) => activityCtx.traceInfo(msg));
1518
- }
1519
- activityCtx.traceInfo(`[runTurn] invoking ManagedSession.runTurn for ${input.sessionId}`);
1520
- // Record turn_started CMS event
1521
- if (catalog) {
1522
- void cmsRetryBestEffort(`runTurn.recordEvent turn_started session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [{
1523
- eventType: "session.turn_started",
1524
- data: { iteration: input.turnIndex ?? 0 },
1525
- }], workerNodeId), (msg) => activityCtx.traceInfo(msg));
1526
- }
1527
- const runTurnWithPrompt = async (targetSession, prompt) => {
1528
- return await targetSession.runTurn(prompt, {
1529
- onEvent,
1530
- modelSummary: sessionManager.getModelSummary(),
1531
- bootstrap: input.bootstrap,
1532
- requiredTool: input.requiredTool,
1533
- cycleOrigin: input.cycleOrigin,
1534
- turnIndex: input.turnIndex,
1535
- controlToolBridge,
1536
- });
1537
- };
1538
- let result = await runTurnWithPrompt(session, effectivePrompt);
1539
- if (result.type === "error" && isLiveSessionLostErrorMessage(result.message)) {
1540
- activityCtx.traceInfo(`[runTurn] live Copilot session lost for ${input.sessionId}; invalidating warm session and attempting recovery`);
1541
- await sessionManager.invalidateWarmSession(input.sessionId, { lockHeld: true }).catch((err) => {
1542
- activityCtx.traceInfo(`[runTurn] warm-session invalidation failed (non-fatal): ${err?.message ?? err}`);
1543
- });
1628
+ activityCtx.traceInfo(`[runTurn] invoking ManagedSession.runTurn for ${input.sessionId}`);
1629
+ // Record turn_started CMS event
1544
1630
  if (catalog) {
1545
- await cmsRetryBestEffort(`runTurn.recordEvent system.message-recovery session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [{
1546
- eventType: "system.message",
1547
- data: {
1548
- content: "The runtime recovered this session after the worker lost the live Copilot session. " +
1549
- "Some very recent in-memory state may have been lost.",
1550
- },
1631
+ void cmsRetryBestEffort(`runTurn.recordEvent turn_started session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [{
1632
+ eventType: "session.turn_started",
1633
+ data: { iteration: input.turnIndex ?? 0 },
1551
1634
  }], workerNodeId), (msg) => activityCtx.traceInfo(msg));
1552
1635
  }
1553
- try {
1554
- session = await sessionManager.getOrCreate(input.sessionId, runConfig, {
1636
+ const runTurnWithPrompt = async (targetSession, prompt) => {
1637
+ return await targetSession.runTurn(prompt, {
1638
+ onEvent,
1639
+ modelSummary: sessionManager.getModelSummary(),
1640
+ bootstrap: input.bootstrap,
1641
+ requiredTool: input.requiredTool,
1642
+ cycleOrigin: input.cycleOrigin,
1555
1643
  turnIndex: input.turnIndex,
1556
- trace,
1557
- lockHeld: true,
1644
+ controlToolBridge,
1558
1645
  });
1559
- }
1560
- catch (err) {
1561
- const recoveryMessage = err?.message || String(err);
1562
- const fatalMessage = isMissingSessionStateErrorMessage(recoveryMessage)
1563
- ? buildUnrecoverableSessionLossMessage(input.sessionId, stripMissingSessionStatePrefix(recoveryMessage))
1564
- : buildUnrecoverableSessionLossMessage(input.sessionId, recoveryMessage);
1565
- activityCtx.traceInfo(`[runTurn] unrecoverable session loss for ${input.sessionId}: ${fatalMessage}`);
1566
- return await failForMissingState(fatalMessage);
1567
- }
1568
- const recoveredPrompt = mergePromptSections([SESSION_RECOVERY_NOTICE, input.prompt]) || input.prompt;
1569
- result = await runTurnWithPrompt(session, recoveredPrompt);
1646
+ };
1647
+ let result = await runTurnWithPrompt(session, effectivePrompt);
1570
1648
  if (result.type === "error" && isLiveSessionLostErrorMessage(result.message)) {
1571
- const fatalMessage = buildUnrecoverableSessionLossMessage(input.sessionId, result.message);
1572
- activityCtx.traceInfo(`[runTurn] recovery re-run still reported lost session for ${input.sessionId}: ${fatalMessage}`);
1573
- return await failForMissingState(fatalMessage);
1574
- }
1575
- }
1576
- if (result.type === "error" && isToolCallTranscriptCorruptionErrorMessage(result.message)) {
1577
- const transcriptError = result.message;
1578
- activityCtx.traceInfo(`[runTurn] corrupted live Copilot transcript for ${input.sessionId}; resetting stored session state and attempting fresh-session replay`);
1579
- await sessionManager.resetSessionState(input.sessionId, { lockHeld: true }).catch((err) => {
1580
- activityCtx.traceInfo(`[runTurn] stored-session reset failed (non-fatal): ${err?.message ?? err}`);
1581
- });
1582
- await recordLossyHandoffEvent(catalog, input.sessionId, workerNodeId, {
1583
- cause: "corrupted_tool_call_transcript_during_run_turn",
1584
- message: "The runtime detected an inconsistent live Copilot transcript and recreated a fresh session to replay the pending turn.",
1585
- detail: transcriptError,
1586
- error: transcriptError,
1587
- recoveryMode: "fresh_session_replay",
1588
- nextStep: "replay_pending_turn_with_recreated_copilot_session",
1589
- ...(input.turnIndex != null ? { iteration: input.turnIndex } : {}),
1590
- }, (failureMessage) => activityCtx.traceInfo(`[runTurn] ${failureMessage}`));
1591
- if (catalog) {
1592
- await cmsRetryBestEffort(`runTurn.recordEvent system.message-corrupted-transcript session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [{
1593
- eventType: "system.message",
1594
- data: {
1595
- content: "The runtime recreated this session after the live Copilot transcript became inconsistent. " +
1596
- "Some recent in-memory work may be missing or partially executed.",
1597
- },
1598
- }], workerNodeId), (msg) => activityCtx.traceInfo(msg));
1599
- }
1600
- try {
1601
- session = await sessionManager.getOrCreate(input.sessionId, runConfig, {
1602
- turnIndex: 0,
1603
- trace,
1604
- lockHeld: true,
1649
+ activityCtx.traceInfo(`[runTurn] live Copilot session lost for ${input.sessionId}; invalidating warm session and attempting recovery`);
1650
+ await sessionManager.invalidateWarmSession(input.sessionId, { lockHeld: true }).catch((err) => {
1651
+ activityCtx.traceInfo(`[runTurn] warm-session invalidation failed (non-fatal): ${err?.message ?? err}`);
1605
1652
  });
1653
+ if (catalog) {
1654
+ await cmsRetryBestEffort(`runTurn.recordEvent system.message-recovery session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [{
1655
+ eventType: "system.message",
1656
+ data: {
1657
+ content: "The runtime recovered this session after the worker lost the live Copilot session. " +
1658
+ "Some very recent in-memory state may have been lost.",
1659
+ },
1660
+ }], workerNodeId), (msg) => activityCtx.traceInfo(msg));
1661
+ }
1662
+ try {
1663
+ session = await sessionManager.getOrCreate(input.sessionId, runConfig, {
1664
+ turnIndex: input.turnIndex,
1665
+ trace,
1666
+ lockHeld: true,
1667
+ });
1668
+ }
1669
+ catch (err) {
1670
+ const recoveryMessage = err?.message || String(err);
1671
+ const fatalMessage = isMissingSessionStateErrorMessage(recoveryMessage)
1672
+ ? buildUnrecoverableSessionLossMessage(input.sessionId, stripMissingSessionStatePrefix(recoveryMessage))
1673
+ : buildUnrecoverableSessionLossMessage(input.sessionId, recoveryMessage);
1674
+ activityCtx.traceInfo(`[runTurn] unrecoverable session loss for ${input.sessionId}: ${fatalMessage}`);
1675
+ return await failForMissingState(fatalMessage);
1676
+ }
1677
+ const recoveredPrompt = mergePromptSections([SESSION_RECOVERY_NOTICE, input.prompt]) || input.prompt;
1678
+ result = await runTurnWithPrompt(session, recoveredPrompt);
1679
+ if (result.type === "error" && isLiveSessionLostErrorMessage(result.message)) {
1680
+ const fatalMessage = buildUnrecoverableSessionLossMessage(input.sessionId, result.message);
1681
+ activityCtx.traceInfo(`[runTurn] recovery re-run still reported lost session for ${input.sessionId}: ${fatalMessage}`);
1682
+ return await failForMissingState(fatalMessage);
1683
+ }
1606
1684
  }
1607
- catch (err) {
1608
- const recoveryMessage = err?.message || String(err);
1609
- const fatalMessage = `Live Copilot transcript became inconsistent for ${input.sessionId}, and fresh-session recovery failed. ${recoveryMessage}`;
1610
- activityCtx.traceInfo(`[runTurn] unrecoverable corrupted transcript for ${input.sessionId}: ${fatalMessage}`);
1611
- return { type: "error", message: fatalMessage };
1685
+ if (result.type === "error" && isToolCallTranscriptCorruptionErrorMessage(result.message)) {
1686
+ const transcriptError = result.message;
1687
+ activityCtx.traceInfo(`[runTurn] corrupted live Copilot transcript for ${input.sessionId}; resetting stored session state and attempting fresh-session replay`);
1688
+ await sessionManager.resetSessionState(input.sessionId, { lockHeld: true }).catch((err) => {
1689
+ activityCtx.traceInfo(`[runTurn] stored-session reset failed (non-fatal): ${err?.message ?? err}`);
1690
+ });
1691
+ await recordLossyHandoffEvent(catalog, input.sessionId, workerNodeId, {
1692
+ cause: "corrupted_tool_call_transcript_during_run_turn",
1693
+ message: "The runtime detected an inconsistent live Copilot transcript and recreated a fresh session to replay the pending turn.",
1694
+ detail: transcriptError,
1695
+ error: transcriptError,
1696
+ recoveryMode: "fresh_session_replay",
1697
+ nextStep: "replay_pending_turn_with_recreated_copilot_session",
1698
+ ...(input.turnIndex != null ? { iteration: input.turnIndex } : {}),
1699
+ }, (failureMessage) => activityCtx.traceInfo(`[runTurn] ${failureMessage}`));
1700
+ if (catalog) {
1701
+ await cmsRetryBestEffort(`runTurn.recordEvent system.message-corrupted-transcript session=${input.sessionId}`, () => catalog.recordEvents(input.sessionId, [{
1702
+ eventType: "system.message",
1703
+ data: {
1704
+ content: "The runtime recreated this session after the live Copilot transcript became inconsistent. " +
1705
+ "Some recent in-memory work may be missing or partially executed.",
1706
+ },
1707
+ }], workerNodeId), (msg) => activityCtx.traceInfo(msg));
1708
+ }
1709
+ try {
1710
+ session = await sessionManager.getOrCreate(input.sessionId, runConfig, {
1711
+ turnIndex: 0,
1712
+ trace,
1713
+ lockHeld: true,
1714
+ });
1715
+ }
1716
+ catch (err) {
1717
+ const recoveryMessage = err?.message || String(err);
1718
+ const fatalMessage = `Live Copilot transcript became inconsistent for ${input.sessionId}, and fresh-session recovery failed. ${recoveryMessage}`;
1719
+ activityCtx.traceInfo(`[runTurn] unrecoverable corrupted transcript for ${input.sessionId}: ${fatalMessage}`);
1720
+ return { type: "error", message: fatalMessage };
1721
+ }
1722
+ const recoveredPrompt = mergePromptSections([CORRUPTED_TRANSCRIPT_REPLAY_NOTICE, input.prompt]) || input.prompt;
1723
+ result = await runTurnWithPrompt(session, recoveredPrompt);
1612
1724
  }
1613
- const recoveredPrompt = mergePromptSections([CORRUPTED_TRANSCRIPT_REPLAY_NOTICE, input.prompt]) || input.prompt;
1614
- result = await runTurnWithPrompt(session, recoveredPrompt);
1615
- }
1616
- if (input.parentSessionId
1617
- && result.type === "completed"
1618
- && typeof result.content === "string"
1619
- && /^QUESTION FOR PARENT:/i.test(result.content.trim())) {
1620
- result = {
1621
- ...result,
1622
- type: "wait",
1623
- seconds: 60,
1624
- reason: "waiting for parent answer",
1625
- content: result.content.trim(),
1626
- };
1627
- }
1628
- activityCtx.traceInfo(`[runTurn] ManagedSession.runTurn completed for ${input.sessionId} type=${result.type}`);
1629
- // Drain event writes before the atomic post-turn writeback records
1630
- // session.turn_completed.
1631
- //
1632
- // Ordering matters here: every per-event CMS write fired by the
1633
- // SDK's onEvent callback (assistant.message, tool calls, usage,
1634
- // etc.) must land in `session_events` with a smaller `seq` than
1635
- // `session.turn_completed`, otherwise downstream consumers that
1636
- // walk events in seq order (e.g. cms-seq-nodemap) see the turn
1637
- // close before its own assistant.message.
1638
- //
1639
- // `runTurn` resolves once the SDK has emitted its final events,
1640
- // but the corresponding `recordEvents` calls were dispatched
1641
- // fire-and-forget. We therefore:
1642
- // 1. Sleep 100ms to let any straggler onEvent callbacks fire
1643
- // and enqueue their CMS write (quiesce window).
1644
- // 2. Drain `pendingEventWrites` and await all of them with
1645
- // allSettled — failed writes were already logged inline,
1646
- // we only need the ordering guarantee.
1647
- // 3. Await the turn_completed insert so subsequent activity
1648
- // logic and any immediate CMS readers see a consistent tail.
1649
- //
1650
- // The 100ms is a deliberate, bounded pause. If it ever needs to
1651
- // grow, prefer a real "SDK turn fully flushed" signal over a
1652
- // larger sleep.
1653
- if (catalog) {
1654
- await new Promise((resolve) => setTimeout(resolve, 100));
1655
- const pendingWritesAtBarrier = pendingEventWrites.splice(0);
1656
- if (pendingWritesAtBarrier.length > 0) {
1657
- await Promise.allSettled(pendingWritesAtBarrier);
1725
+ if (input.parentSessionId
1726
+ && result.type === "completed"
1727
+ && typeof result.content === "string"
1728
+ && /^QUESTION FOR PARENT:/i.test(result.content.trim())) {
1729
+ result = {
1730
+ ...result,
1731
+ type: "wait",
1732
+ seconds: 60,
1733
+ reason: "waiting for parent answer",
1734
+ content: result.content.trim(),
1735
+ };
1658
1736
  }
1659
- }
1660
- if (cancelled)
1661
- return { type: "cancelled" };
1662
- // ── Activity-level writeback: sync turn result → CMS ──
1663
- // This lets listSessions() read entirely from CMS without
1664
- // hitting duroxide for every session's customStatus.
1665
- if (catalog) {
1666
- const statusMap = {
1667
- completed: "idle", // orchestration decides idle vs completed; default to idle
1668
- wait: "waiting",
1669
- cron: "running",
1670
- input_required: "input_required",
1671
- error: "error",
1672
- cancelled: "idle",
1673
- stopped: "idle",
1674
- spawn_agent: "running",
1675
- message_agent: "running",
1676
- check_agents: "running",
1677
- wait_for_agents: "waiting",
1678
- list_sessions: "running",
1679
- complete_agent: "running",
1680
- cancel_agent: "running",
1681
- delete_agent: "running",
1682
- };
1683
- const liveStatus = statusMap[result.type] ?? "idle";
1684
- const updates = {
1685
- state: liveStatus,
1686
- lastActiveAt: new Date(),
1687
- };
1688
- if (result.type === "error") {
1689
- updates.lastError = result.message ?? null;
1690
- updates.waitReason = null;
1737
+ activityCtx.traceInfo(`[runTurn] ManagedSession.runTurn completed for ${input.sessionId} type=${result.type}`);
1738
+ // Drain event writes before the atomic post-turn writeback records
1739
+ // session.turn_completed.
1740
+ //
1741
+ // Ordering matters here: every per-event CMS write fired by the
1742
+ // SDK's onEvent callback (assistant.message, tool calls, usage,
1743
+ // etc.) must land in `session_events` with a smaller `seq` than
1744
+ // `session.turn_completed`, otherwise downstream consumers that
1745
+ // walk events in seq order (e.g. cms-seq-nodemap) see the turn
1746
+ // close before its own assistant.message.
1747
+ //
1748
+ // `runTurn` resolves once the SDK has emitted its final events,
1749
+ // but the corresponding `recordEvents` calls were dispatched
1750
+ // fire-and-forget. We therefore:
1751
+ // 1. Sleep 100ms to let any straggler onEvent callbacks fire
1752
+ // and enqueue their CMS write (quiesce window).
1753
+ // 2. Drain `pendingEventWrites` and await all of them with
1754
+ // allSettled — failed writes were already logged inline,
1755
+ // we only need the ordering guarantee.
1756
+ // 3. Await the turn_completed insert so subsequent activity
1757
+ // logic and any immediate CMS readers see a consistent tail.
1758
+ //
1759
+ // The 100ms is a deliberate, bounded pause. If it ever needs to
1760
+ // grow, prefer a real "SDK turn fully flushed" signal over a
1761
+ // larger sleep.
1762
+ if (catalog) {
1763
+ await new Promise((resolve) => setTimeout(resolve, 100));
1764
+ const pendingWritesAtBarrier = pendingEventWrites.splice(0);
1765
+ if (pendingWritesAtBarrier.length > 0) {
1766
+ await Promise.allSettled(pendingWritesAtBarrier);
1767
+ }
1691
1768
  }
1692
- else if (result.type === "wait") {
1693
- updates.waitReason = result.reason ?? null;
1694
- updates.lastError = null;
1769
+ if (cancelled)
1770
+ return { type: "cancelled" };
1771
+ // ── Activity-level writeback: sync turn result → CMS ──
1772
+ // This lets listSessions() read entirely from CMS without
1773
+ // hitting duroxide for every session's customStatus.
1774
+ if (catalog) {
1775
+ const statusMap = {
1776
+ completed: "idle", // orchestration decides idle vs completed; default to idle
1777
+ wait: "waiting",
1778
+ cron: "running",
1779
+ input_required: "input_required",
1780
+ error: "error",
1781
+ cancelled: "idle",
1782
+ stopped: "idle",
1783
+ spawn_agent: "running",
1784
+ message_agent: "running",
1785
+ check_agents: "running",
1786
+ wait_for_agents: "waiting",
1787
+ list_sessions: "running",
1788
+ complete_agent: "running",
1789
+ cancel_agent: "running",
1790
+ delete_agent: "running",
1791
+ };
1792
+ const liveStatus = statusMap[result.type] ?? "idle";
1793
+ const updates = {
1794
+ state: liveStatus,
1795
+ lastActiveAt: new Date(),
1796
+ };
1797
+ if (result.type === "error") {
1798
+ updates.lastError = result.message ?? null;
1799
+ updates.waitReason = null;
1800
+ }
1801
+ else if (result.type === "wait") {
1802
+ updates.waitReason = result.reason ?? null;
1803
+ updates.lastError = null;
1804
+ }
1805
+ else if (result.type === "input_required") {
1806
+ updates.waitReason = result.question ?? null;
1807
+ updates.lastError = null;
1808
+ }
1809
+ else {
1810
+ updates.waitReason = null;
1811
+ updates.lastError = null;
1812
+ }
1813
+ const turnEndedAt = new Date();
1814
+ await cmsRetryBestEffort(`runTurn.postTurn completeTurnWriteback state=${updates.state} session=${input.sessionId}`, () => catalog.completeTurnWriteback({
1815
+ sessionId: input.sessionId,
1816
+ agentId: null,
1817
+ model: input.config.model ?? null,
1818
+ reasoningEffort: input.config.reasoningEffort ?? null,
1819
+ turnIndex: input.turnIndex ?? 0,
1820
+ startedAt: turnStartedAt,
1821
+ endedAt: turnEndedAt,
1822
+ durationMs: Math.max(0, turnEndedAt.getTime() - turnStartedAt.getTime()),
1823
+ tokensInput: turnTelemetry.tokensInput,
1824
+ tokensOutput: turnTelemetry.tokensOutput,
1825
+ tokensCacheRead: turnTelemetry.tokensCacheRead,
1826
+ tokensCacheWrite: turnTelemetry.tokensCacheWrite,
1827
+ toolCalls: turnTelemetry.toolCalls,
1828
+ toolErrors: turnTelemetry.toolErrors,
1829
+ toolNames: Array.from(turnTelemetry.toolNames).sort(),
1830
+ resultType: result.type,
1831
+ errorMessage: result.type === "error" ? (result.message ?? null) : null,
1832
+ workerNodeId: workerNodeId ?? null,
1833
+ state: updates.state ?? "idle",
1834
+ lastActiveAt: updates.lastActiveAt ?? turnEndedAt,
1835
+ lastError: updates.lastError ?? null,
1836
+ waitReason: updates.waitReason ?? null,
1837
+ currentIteration: input.turnIndex ?? 0,
1838
+ }), (msg) => activityCtx.traceInfo(msg));
1695
1839
  }
1696
- else if (result.type === "input_required") {
1697
- updates.waitReason = result.question ?? null;
1698
- updates.lastError = null;
1840
+ return result;
1841
+ };
1842
+ const bodyResult = await executeTurnBody();
1843
+ // ── Session lifecycle protocol commit (proposal §3.2) ───────────
1844
+ // The turn and its snapshot durability are one activity completion:
1845
+ // no observable state exists where the turn "happened" but its
1846
+ // snapshot doesn't. Commit on every result the body produced —
1847
+ // matching today's semantics where the warm session keeps whatever
1848
+ // state the turn (even a cancelled/errored one) left behind. The
1849
+ // layout guard skips paths where no snapshottable session exists:
1850
+ // the SDK writes workspace.yaml on session create, so a dir without
1851
+ // it (fake test clients, failed creates — possibly containing only
1852
+ // our own sentinel) has nothing the store could meaningfully hold,
1853
+ // and committing it would fail the whole turn.
1854
+ const lifecycleSessionDir = lifecycle
1855
+ ? path.join(sessionManager.getSessionStateDir(), input.sessionId)
1856
+ : null;
1857
+ if (lifecycle && lifecycleSessionDir && fs.existsSync(path.join(lifecycleSessionDir, "workspace.yaml"))) {
1858
+ const committed = await runTurnCommit(lifecycle, lifecycleBaseVersion, bodyResult);
1859
+ if (catalog && committed.sizeBytes != null) {
1860
+ await cmsRetryBestEffort(`runTurn.commitSummary session=${input.sessionId}`, () => catalog.upsertSessionMetricSummary(input.sessionId, {
1861
+ snapshotSizeBytes: committed.sizeBytes,
1862
+ ...(committed.rawSizeBytes != null ? { rawSizeBytes: committed.rawSizeBytes } : {}),
1863
+ }), (msg) => activityCtx.traceInfo(msg));
1699
1864
  }
1700
- else {
1701
- updates.waitReason = null;
1702
- updates.lastError = null;
1865
+ if (committed.alreadyCommitted && committed.storedResult !== undefined) {
1866
+ // A racing attempt of this same turn won the CAS. Its
1867
+ // snapshot was restored (restore-not-replay, §3.2 r1–r3)
1868
+ // return ITS result so the recorded outcome matches the
1869
+ // durable lineage instead of this attempt's divergent body.
1870
+ activityCtx.traceInfo(`[runTurn] session=${input.sessionId} racing attempt committed v${committed.version} first; ` +
1871
+ `adopting its stored result`);
1872
+ return { ...committed.storedResult, snapshotVersion: committed.version };
1703
1873
  }
1704
- const turnEndedAt = new Date();
1705
- await cmsRetryBestEffort(`runTurn.postTurn completeTurnWriteback state=${updates.state} session=${input.sessionId}`, () => catalog.completeTurnWriteback({
1706
- sessionId: input.sessionId,
1707
- agentId: null,
1708
- model: input.config.model ?? null,
1709
- reasoningEffort: input.config.reasoningEffort ?? null,
1710
- turnIndex: input.turnIndex ?? 0,
1711
- startedAt: turnStartedAt,
1712
- endedAt: turnEndedAt,
1713
- durationMs: Math.max(0, turnEndedAt.getTime() - turnStartedAt.getTime()),
1714
- tokensInput: turnTelemetry.tokensInput,
1715
- tokensOutput: turnTelemetry.tokensOutput,
1716
- tokensCacheRead: turnTelemetry.tokensCacheRead,
1717
- tokensCacheWrite: turnTelemetry.tokensCacheWrite,
1718
- toolCalls: turnTelemetry.toolCalls,
1719
- toolErrors: turnTelemetry.toolErrors,
1720
- toolNames: Array.from(turnTelemetry.toolNames).sort(),
1721
- resultType: result.type,
1722
- errorMessage: result.type === "error" ? (result.message ?? null) : null,
1723
- workerNodeId: workerNodeId ?? null,
1724
- state: updates.state ?? "idle",
1725
- lastActiveAt: updates.lastActiveAt ?? turnEndedAt,
1726
- lastError: updates.lastError ?? null,
1727
- waitReason: updates.waitReason ?? null,
1728
- currentIteration: input.turnIndex ?? 0,
1729
- }), (msg) => activityCtx.traceInfo(msg));
1874
+ return { ...bodyResult, snapshotVersion: committed.version };
1730
1875
  }
1731
- return result;
1876
+ return bodyResult;
1732
1877
  }, { trace });
1733
1878
  if (!finalTurnResult) {
1734
1879
  throw new Error("runTurn completed without a turn result");
@@ -1808,6 +1953,21 @@ workerNodeId) {
1808
1953
  activityCtx.traceInfo(`[abortTurn] session=${input.sessionId} outcome=${result.outcome}${result.detail ? ` (${result.detail})` : ""}`);
1809
1954
  return result;
1810
1955
  });
1956
+ // ── LEGACY LIFECYCLE ACTIVITIES (compat surface, not dead code) ──
1957
+ //
1958
+ // dehydrateSession / hydrateSession / needsHydrationSession /
1959
+ // checkpointSession are scheduled ONLY by frozen orchestration versions
1960
+ // (≤ 1.0.56: their processPrompt probes needsHydrationSession before
1961
+ // every turn, their wait policy dehydrates, their warm continue-as-new
1962
+ // checkpoints) and by 1.0.57's one-shot normalization of a legacy CAN
1963
+ // input (hydrateSession when input.needsHydration=true). The lifecycle
1964
+ // protocol itself never schedules any of them — hydrate/commit live
1965
+ // inside the runTurn activity (P5).
1966
+ //
1967
+ // Retirement condition: when the ≤1.0.56 handlers are dropped from
1968
+ // DURABLE_SESSION_ORCHESTRATION_REGISTRY, delete these registrations
1969
+ // (and the session-proxy methods that schedule them) in the same
1970
+ // change. Until then they must stay byte-compatible.
1811
1971
  // ── dehydrateSession ────────────────────────────────────
1812
1972
  runtime.registerActivity("dehydrateSession", async (activityCtx, input) => {
1813
1973
  const reason = input.reason ?? "unknown";
@@ -1880,7 +2040,7 @@ workerNodeId) {
1880
2040
  trace(`session=${input.sessionId} complete reason=${reason}`);
1881
2041
  dehydrationSpan.setAttribute("pilotswarm.dehydration_result", "completed");
1882
2042
  if (catalog) {
1883
- const snapshotSizeBytes = await tryReadSnapshotSizeBytes(_sessionStore, input.sessionId);
2043
+ const snapshotSizeBytes = await tryReadSnapshotSizeBytes(sessionStore, input.sessionId);
1884
2044
  if (snapshotSizeBytes != null) {
1885
2045
  dehydrationSpan.setAttribute("pilotswarm.snapshot_size_bytes", snapshotSizeBytes);
1886
2046
  }
@@ -1967,7 +2127,7 @@ workerNodeId) {
1967
2127
  runtime.registerActivity("checkpointSession", async (activityCtx, input) => {
1968
2128
  await sessionManager.checkpoint(input.sessionId);
1969
2129
  if (catalog) {
1970
- const snapshotSizeBytes = await tryReadSnapshotSizeBytes(_sessionStore, input.sessionId);
2130
+ const snapshotSizeBytes = await tryReadSnapshotSizeBytes(sessionStore, input.sessionId);
1971
2131
  // Best-effort: metric summary is observability only. The blob has
1972
2132
  // already been written by sessionManager.checkpoint above.
1973
2133
  await cmsRetryBestEffort(`checkpointSession.upsertSummary session=${input.sessionId}`, () => catalog.upsertSessionMetricSummary(input.sessionId, {
@@ -2171,6 +2331,7 @@ workerNodeId) {
2171
2331
  id: agent.id ?? undefined,
2172
2332
  parent: agent.parent ?? undefined,
2173
2333
  splash: agent.splash ?? undefined,
2334
+ splashMobile: agent.splashMobile ?? undefined,
2174
2335
  namespace: agent.namespace ?? undefined,
2175
2336
  promptLayerKind: agent.promptLayerKind ?? undefined,
2176
2337
  creatable: agent.creatable ?? !agent.system,
@@ -2286,6 +2447,15 @@ workerNodeId) {
2286
2447
  meta.agentId = input.agentId;
2287
2448
  if (input.splash)
2288
2449
  meta.splash = input.splash;
2450
+ // Frozen orchestration versions don't thread splashMobile through
2451
+ // the spawn input, so resolve it from the loaded agent definition.
2452
+ if (input.agentId) {
2453
+ const normalizeId = (value) => (value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
2454
+ const lookup = normalizeId(input.agentId);
2455
+ const agentDef = [...(userAgents ?? []), ...(systemAgents ?? [])].find((a) => [a.id, a.name].map(normalizeId).filter(Boolean).includes(lookup));
2456
+ if (agentDef?.splashMobile)
2457
+ meta.splashMobile = agentDef.splashMobile;
2458
+ }
2289
2459
  if (Object.keys(meta).length > 0 && catalog) {
2290
2460
  const metaAt = Date.now();
2291
2461
  // Critical: title/agentId/splash drive UI rendering of the