@poncho-ai/cli 0.30.0 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1391,6 +1391,11 @@ export type RequestHandler = ((
1391
1391
  _cronJobs?: Record<string, CronJobConfig>;
1392
1392
  _conversationStore?: ConversationStore;
1393
1393
  _messagingAdapters?: Map<string, MessagingAdapter>;
1394
+ _activeConversationRuns?: Map<string, { ownerId: string; abortController: AbortController; runId: string | null }>;
1395
+ _pendingCallbackNeeded?: Set<string>;
1396
+ _processSubagentCallback?: (conversationId: string, skipLockCheck?: boolean) => Promise<void>;
1397
+ _broadcastEvent?: (conversationId: string, event: AgentEvent) => void;
1398
+ _finishConversationStream?: (conversationId: string) => void;
1394
1399
  };
1395
1400
 
1396
1401
  export const createRequestHandler = async (options?: {
@@ -1573,6 +1578,11 @@ export const createRequestHandler = async (options?: {
1573
1578
  };
1574
1579
  const pendingSubagentApprovals = new Map<string, PendingSubagentApproval>();
1575
1580
 
1581
+ // Tracks approval decisions in memory so parallel batch requests don't
1582
+ // race against the conversation store (each file-store read returns a
1583
+ // separate copy, causing last-writer-wins when decisions overlap).
1584
+ const approvalDecisionTracker = new Map<string, Map<string, boolean>>();
1585
+
1576
1586
  const getSubagentDepth = async (conversationId: string): Promise<number> => {
1577
1587
  let depth = 0;
1578
1588
  let current = await conversationStore.get(conversationId);
@@ -2042,8 +2052,15 @@ export const createRequestHandler = async (options?: {
2042
2052
  // ---------------------------------------------------------------------------
2043
2053
  const MAX_SUBAGENT_CALLBACK_COUNT = 20;
2044
2054
 
2055
+ // Track conversations that received subagent results while a run was active.
2056
+ // processSubagentCallback's finally block checks this to reliably re-trigger
2057
+ // even if the store-level pendingSubagentResults was clobbered by a concurrent
2058
+ // read-modify-write.
2059
+ const pendingCallbackNeeded = new Set<string>();
2060
+
2045
2061
  const triggerParentCallback = async (parentConversationId: string): Promise<void> => {
2046
2062
  if (activeConversationRuns.has(parentConversationId)) {
2063
+ pendingCallbackNeeded.add(parentConversationId);
2047
2064
  return;
2048
2065
  }
2049
2066
  if (isServerless) {
@@ -2057,15 +2074,17 @@ export const createRequestHandler = async (options?: {
2057
2074
 
2058
2075
  const CALLBACK_LOCK_STALE_MS = 5 * 60 * 1000;
2059
2076
 
2060
- const processSubagentCallback = async (conversationId: string): Promise<void> => {
2077
+ const processSubagentCallback = async (conversationId: string, skipLockCheck = false): Promise<void> => {
2061
2078
  const conversation = await conversationStore.get(conversationId);
2062
2079
  if (!conversation) return;
2063
2080
 
2064
2081
  const pendingResults = conversation.pendingSubagentResults ?? [];
2065
2082
  if (pendingResults.length === 0) return;
2066
2083
 
2067
- // Store-based lock for serverless: skip if another invocation is processing
2068
- if (conversation.runningCallbackSince) {
2084
+ // Store-based lock for serverless: skip if another invocation is processing.
2085
+ // When re-triggered from a previous callback's finally block, skipLockCheck
2086
+ // is true because we know the previous callback has finished.
2087
+ if (!skipLockCheck && conversation.runningCallbackSince) {
2069
2088
  const elapsed = Date.now() - conversation.runningCallbackSince;
2070
2089
  if (elapsed < CALLBACK_LOCK_STALE_MS) {
2071
2090
  return;
@@ -2109,12 +2128,27 @@ export const createRequestHandler = async (options?: {
2109
2128
  abortController,
2110
2129
  runId: null,
2111
2130
  });
2131
+ // Reopen/reset the parent stream for this callback run so clients that stay
2132
+ // on the main conversation can subscribe to live callback events.
2133
+ const prevStream = conversationEventStreams.get(conversationId);
2134
+ if (prevStream) {
2135
+ prevStream.finished = false;
2136
+ prevStream.buffer = [];
2137
+ } else {
2138
+ conversationEventStreams.set(conversationId, {
2139
+ buffer: [],
2140
+ subscribers: new Set(),
2141
+ finished: false,
2142
+ });
2143
+ }
2112
2144
 
2113
2145
  const historyMessages = [...conversation.messages];
2114
2146
  let assistantResponse = "";
2115
2147
  let latestRunId = "";
2116
2148
  let runContinuation = false;
2117
2149
  let runContinuationMessages: Message[] | undefined;
2150
+ let runContextTokens = conversation.contextTokens ?? 0;
2151
+ let runContextWindow = conversation.contextWindow ?? 0;
2118
2152
  const toolTimeline: string[] = [];
2119
2153
  const sections: Array<{ type: "text" | "tools"; content: string | string[] }> = [];
2120
2154
  let currentTools: string[] = [];
@@ -2170,6 +2204,8 @@ export const createRequestHandler = async (options?: {
2170
2204
  if (assistantResponse.length === 0 && event.result.response) {
2171
2205
  assistantResponse = event.result.response;
2172
2206
  }
2207
+ runContextTokens = event.result.contextTokens ?? runContextTokens;
2208
+ runContextWindow = event.result.contextWindow ?? runContextWindow;
2173
2209
  if (event.result.continuation) {
2174
2210
  runContinuation = true;
2175
2211
  if (event.result.continuationMessages) {
@@ -2200,6 +2236,8 @@ export const createRequestHandler = async (options?: {
2200
2236
  }
2201
2237
  freshConv.runtimeRunId = latestRunId || freshConv.runtimeRunId;
2202
2238
  freshConv.runningCallbackSince = undefined;
2239
+ if (runContextTokens > 0) freshConv.contextTokens = runContextTokens;
2240
+ if (runContextWindow > 0) freshConv.contextWindow = runContextWindow;
2203
2241
  freshConv.updatedAt = Date.now();
2204
2242
  await conversationStore.update(freshConv);
2205
2243
 
@@ -2242,24 +2280,43 @@ export const createRequestHandler = async (options?: {
2242
2280
  activeConversationRuns.delete(conversationId);
2243
2281
  finishConversationStream(conversationId);
2244
2282
 
2283
+ // Check both the in-memory flag (always reliable) and the store.
2284
+ // We drain the flag first so a concurrent triggerParentCallback that
2285
+ // sets it right after our delete above is still caught on the next
2286
+ // iteration.
2287
+ const hadDeferredTrigger = pendingCallbackNeeded.delete(conversationId);
2245
2288
  const freshConv = await conversationStore.get(conversationId);
2246
- if (freshConv) {
2247
- if (freshConv.runningCallbackSince) {
2248
- freshConv.runningCallbackSince = undefined;
2249
- await conversationStore.update(freshConv);
2250
- }
2251
- }
2289
+ const hasPendingInStore = !!freshConv?.pendingSubagentResults?.length;
2252
2290
 
2253
- if (freshConv?.pendingSubagentResults?.length) {
2291
+ if (hadDeferredTrigger || hasPendingInStore) {
2292
+ // Re-trigger immediately. Skip the runningCallbackSince lock check
2293
+ // because we know this callback just finished. The re-triggered
2294
+ // callback will overwrite runningCallbackSince with its own timestamp.
2254
2295
  if (isServerless) {
2255
2296
  selfFetchWithRetry(`/api/internal/conversations/${encodeURIComponent(conversationId)}/subagent-callback`).catch(err =>
2256
2297
  console.error(`[poncho][subagent-callback] Recursive callback self-fetch failed:`, err instanceof Error ? err.message : err),
2257
2298
  );
2258
2299
  } else {
2259
- processSubagentCallback(conversationId).catch(err =>
2300
+ processSubagentCallback(conversationId, true).catch(err =>
2260
2301
  console.error(`[poncho][subagent-callback] Recursive callback failed:`, err instanceof Error ? err.message : err),
2261
2302
  );
2262
2303
  }
2304
+ } else if (freshConv?.runningCallbackSince) {
2305
+ // No re-trigger needed. Use the atomic clearCallbackLock to avoid
2306
+ // clobbering concurrent appendSubagentResult writes.
2307
+ const afterClear = await conversationStore.clearCallbackLock(conversationId);
2308
+ // Double-check: an append may have raced even the atomic clear
2309
+ if (afterClear?.pendingSubagentResults?.length) {
2310
+ if (isServerless) {
2311
+ selfFetchWithRetry(`/api/internal/conversations/${encodeURIComponent(conversationId)}/subagent-callback`).catch(err =>
2312
+ console.error(`[poncho][subagent-callback] Post-clear callback self-fetch failed:`, err instanceof Error ? err.message : err),
2313
+ );
2314
+ } else {
2315
+ processSubagentCallback(conversationId, true).catch(err =>
2316
+ console.error(`[poncho][subagent-callback] Post-clear callback failed:`, err instanceof Error ? err.message : err),
2317
+ );
2318
+ }
2319
+ }
2263
2320
  }
2264
2321
  }
2265
2322
  };
@@ -2461,14 +2518,6 @@ export const createRequestHandler = async (options?: {
2461
2518
  if (active && active.abortController === abortController) {
2462
2519
  active.runId = event.runId;
2463
2520
  }
2464
- if (typeof event.contextWindow === "number" && event.contextWindow > 0) {
2465
- runContextWindow = event.contextWindow;
2466
- }
2467
- }
2468
- if (event.type === "model:response") {
2469
- if (typeof event.usage?.input === "number") {
2470
- runContextTokens = event.usage.input;
2471
- }
2472
2521
  }
2473
2522
  if (event.type === "model:chunk") {
2474
2523
  if (currentTools.length > 0) {
@@ -2533,12 +2582,12 @@ export const createRequestHandler = async (options?: {
2533
2582
  }
2534
2583
  checkpointedRun = true;
2535
2584
  }
2536
- if (
2537
- event.type === "run:completed" &&
2538
- assistantResponse.length === 0 &&
2539
- event.result.response
2540
- ) {
2541
- assistantResponse = event.result.response;
2585
+ if (event.type === "run:completed") {
2586
+ if (assistantResponse.length === 0 && event.result.response) {
2587
+ assistantResponse = event.result.response;
2588
+ }
2589
+ runContextTokens = event.result.contextTokens ?? runContextTokens;
2590
+ runContextWindow = event.result.contextWindow ?? runContextWindow;
2542
2591
  }
2543
2592
  if (event.type === "run:error") {
2544
2593
  assistantResponse = assistantResponse || `[Error: ${event.error.message}]`;
@@ -2627,6 +2676,15 @@ export const createRequestHandler = async (options?: {
2627
2676
  runConversations.delete(latestRunId);
2628
2677
  }
2629
2678
  console.log("[resume-run] complete for", conversationId);
2679
+
2680
+ // Check for pending subagent results that arrived during the run
2681
+ const hadDeferred = pendingCallbackNeeded.delete(conversationId);
2682
+ const postConv = await conversationStore.get(conversationId);
2683
+ if (hadDeferred || postConv?.pendingSubagentResults?.length) {
2684
+ processSubagentCallback(conversationId, true).catch(err =>
2685
+ console.error(`[poncho][subagent-callback] Post-resume callback failed:`, err instanceof Error ? err.message : err),
2686
+ );
2687
+ }
2630
2688
  };
2631
2689
 
2632
2690
  // ---------------------------------------------------------------------------
@@ -2783,14 +2841,6 @@ export const createRequestHandler = async (options?: {
2783
2841
  latestRunId = event.runId;
2784
2842
  runOwners.set(event.runId, "local-owner");
2785
2843
  runConversations.set(event.runId, conversationId);
2786
- if (typeof event.contextWindow === "number" && event.contextWindow > 0) {
2787
- runContextWindow = event.contextWindow;
2788
- }
2789
- }
2790
- if (event.type === "model:response") {
2791
- if (typeof event.usage?.input === "number") {
2792
- runContextTokens = event.usage.input;
2793
- }
2794
2844
  }
2795
2845
  if (event.type === "model:chunk") {
2796
2846
  if (currentTools.length > 0) {
@@ -2892,6 +2942,8 @@ export const createRequestHandler = async (options?: {
2892
2942
  }
2893
2943
  runSteps = event.result.steps;
2894
2944
  if (typeof event.result.maxSteps === "number") runMaxSteps = event.result.maxSteps;
2945
+ runContextTokens = event.result.contextTokens ?? runContextTokens;
2946
+ runContextWindow = event.result.contextWindow ?? runContextWindow;
2895
2947
  }
2896
2948
  if (event.type === "run:error") {
2897
2949
  assistantResponse = assistantResponse || `[Error: ${event.error.message}]`;
@@ -3813,7 +3865,15 @@ export const createRequestHandler = async (options?: {
3813
3865
  return;
3814
3866
  }
3815
3867
 
3816
- // Record the decision on this approval entry
3868
+ // Track decision in memory so parallel batch requests see a consistent
3869
+ // view (file-store reads return independent copies, causing lost updates).
3870
+ let batchDecisions = approvalDecisionTracker.get(conversationId);
3871
+ if (!batchDecisions) {
3872
+ batchDecisions = new Map();
3873
+ approvalDecisionTracker.set(conversationId, batchDecisions);
3874
+ }
3875
+ batchDecisions.set(approvalId, approved);
3876
+
3817
3877
  foundApproval.decision = approved ? "approved" : "denied";
3818
3878
 
3819
3879
  broadcastEvent(conversationId,
@@ -3823,16 +3883,26 @@ export const createRequestHandler = async (options?: {
3823
3883
  );
3824
3884
 
3825
3885
  const allApprovals = foundConversation.pendingApprovals ?? [];
3826
- const allDecided = allApprovals.length > 0 && allApprovals.every(a => a.decision != null);
3886
+ const allDecided = allApprovals.length > 0 &&
3887
+ allApprovals.every(a => batchDecisions!.has(a.approvalId));
3827
3888
 
3828
3889
  if (!allDecided) {
3829
- // Still waiting for more decisions — persist and respond
3890
+ // Still waiting for more decisions — persist best-effort and respond.
3891
+ // The write may be overwritten by a concurrent request, but that's
3892
+ // fine: the in-memory tracker is the source of truth for completion.
3830
3893
  await conversationStore.update(foundConversation);
3831
3894
  writeJson(response, 200, { ok: true, approvalId, approved, batchComplete: false });
3832
3895
  return;
3833
3896
  }
3834
3897
 
3835
- // All approvals in the batch are decided — execute and resume
3898
+ // All approvals in the batch are decided — apply tracked decisions,
3899
+ // execute approved tools, and resume the run.
3900
+ for (const a of allApprovals) {
3901
+ const d = batchDecisions.get(a.approvalId);
3902
+ if (d != null) a.decision = d ? "approved" : "denied";
3903
+ }
3904
+ approvalDecisionTracker.delete(conversationId);
3905
+
3836
3906
  foundConversation.pendingApprovals = [];
3837
3907
  foundConversation.runStatus = "running";
3838
3908
  await conversationStore.update(foundConversation);
@@ -4517,14 +4587,6 @@ export const createRequestHandler = async (options?: {
4517
4587
  if (active && active.abortController === abortController) {
4518
4588
  active.runId = event.runId;
4519
4589
  }
4520
- if (typeof event.contextWindow === "number" && event.contextWindow > 0) {
4521
- runContextWindow = event.contextWindow;
4522
- }
4523
- }
4524
- if (event.type === "model:response") {
4525
- if (typeof event.usage?.input === "number") {
4526
- runContextTokens = event.usage.input;
4527
- }
4528
4590
  }
4529
4591
  if (event.type === "run:cancelled") {
4530
4592
  runCancelled = true;
@@ -4623,6 +4685,8 @@ export const createRequestHandler = async (options?: {
4623
4685
  if (assistantResponse.length === 0 && event.result.response) {
4624
4686
  assistantResponse = event.result.response;
4625
4687
  }
4688
+ runContextTokens = event.result.contextTokens ?? runContextTokens;
4689
+ runContextWindow = event.result.contextWindow ?? runContextWindow;
4626
4690
  if (event.result.continuation && event.result.continuationMessages) {
4627
4691
  runContinuationMessages = event.result.continuationMessages;
4628
4692
  conversation._continuationMessages = runContinuationMessages;
@@ -4783,9 +4847,10 @@ export const createRequestHandler = async (options?: {
4783
4847
  // Already closed.
4784
4848
  }
4785
4849
  // Check for pending subagent results that arrived during the run
4850
+ const hadDeferred = pendingCallbackNeeded.delete(conversationId);
4786
4851
  const freshConv = await conversationStore.get(conversationId);
4787
- if (freshConv?.pendingSubagentResults?.length) {
4788
- processSubagentCallback(conversationId).catch(err =>
4852
+ if (hadDeferred || freshConv?.pendingSubagentResults?.length) {
4853
+ processSubagentCallback(conversationId, true).catch(err =>
4789
4854
  console.error(`[poncho][subagent-callback] Post-run callback failed:`, err instanceof Error ? err.message : err),
4790
4855
  );
4791
4856
  }
@@ -4948,6 +5013,14 @@ export const createRequestHandler = async (options?: {
4948
5013
  );
4949
5014
  }
4950
5015
 
5016
+ const convId = conversation.conversationId;
5017
+ activeConversationRuns.set(convId, {
5018
+ ownerId: conversation.ownerId,
5019
+ abortController: new AbortController(),
5020
+ runId: null,
5021
+ });
5022
+
5023
+ try {
4951
5024
  const abortController = new AbortController();
4952
5025
  let assistantResponse = "";
4953
5026
  let latestRunId = "";
@@ -4955,7 +5028,7 @@ export const createRequestHandler = async (options?: {
4955
5028
  const sections: Array<{ type: "text" | "tools"; content: string | string[] }> = [];
4956
5029
  let currentTools: string[] = [];
4957
5030
  let currentText = "";
4958
- let runResult: { status: string; steps: number; continuation?: boolean } = {
5031
+ let runResult: { status: string; steps: number; continuation?: boolean; contextTokens?: number; contextWindow?: number } = {
4959
5032
  status: "completed",
4960
5033
  steps: 0,
4961
5034
  };
@@ -4967,8 +5040,8 @@ export const createRequestHandler = async (options?: {
4967
5040
 
4968
5041
  for await (const event of harness.runWithTelemetry({
4969
5042
  task: cronJob.task,
4970
- conversationId: conversation.conversationId,
4971
- parameters: { __activeConversationId: conversation.conversationId },
5043
+ conversationId: convId,
5044
+ parameters: { __activeConversationId: convId },
4972
5045
  messages: historyMessages,
4973
5046
  abortSignal: abortController.signal,
4974
5047
  })) {
@@ -5010,14 +5083,19 @@ export const createRequestHandler = async (options?: {
5010
5083
  status: event.result.status,
5011
5084
  steps: event.result.steps,
5012
5085
  continuation: event.result.continuation,
5086
+ contextTokens: event.result.contextTokens,
5087
+ contextWindow: event.result.contextWindow,
5013
5088
  };
5014
5089
  if (!assistantResponse && event.result.response) {
5015
5090
  assistantResponse = event.result.response;
5016
5091
  }
5017
5092
  }
5093
+ broadcastEvent(convId, event);
5018
5094
  await telemetry.emit(event);
5019
5095
  }
5020
5096
 
5097
+ finishConversationStream(convId);
5098
+
5021
5099
  if (currentTools.length > 0) {
5022
5100
  sections.push({ type: "tools", content: currentTools });
5023
5101
  }
@@ -5026,7 +5104,8 @@ export const createRequestHandler = async (options?: {
5026
5104
  currentText = "";
5027
5105
  }
5028
5106
 
5029
- // Persist the conversation
5107
+ // Persist the conversation — read fresh state to avoid clobbering
5108
+ // pendingSubagentResults appended during the run.
5030
5109
  const hasContent = assistantResponse.length > 0 || toolTimeline.length > 0;
5031
5110
  const assistantMetadata =
5032
5111
  toolTimeline.length > 0 || sections.length > 0
@@ -5044,14 +5123,19 @@ export const createRequestHandler = async (options?: {
5044
5123
  ? [{ role: "assistant" as const, content: assistantResponse, metadata: assistantMetadata }]
5045
5124
  : []),
5046
5125
  ];
5047
- conversation.messages = messages;
5048
- conversation.runtimeRunId = latestRunId || conversation.runtimeRunId;
5049
- conversation.updatedAt = Date.now();
5050
- await conversationStore.update(conversation);
5126
+ const freshConv = await conversationStore.get(convId);
5127
+ if (freshConv) {
5128
+ freshConv.messages = messages;
5129
+ freshConv.runtimeRunId = latestRunId || freshConv.runtimeRunId;
5130
+ if (runResult.contextTokens) freshConv.contextTokens = runResult.contextTokens;
5131
+ if (runResult.contextWindow) freshConv.contextWindow = runResult.contextWindow;
5132
+ freshConv.updatedAt = Date.now();
5133
+ await conversationStore.update(freshConv);
5134
+ }
5051
5135
 
5052
5136
  // Self-continuation for serverless timeouts
5053
5137
  if (runResult.continuation && softDeadlineMs > 0) {
5054
- const selfUrl = `http://${request.headers.host ?? "localhost"}${pathname}?continue=${encodeURIComponent(conversation.conversationId)}&continuation=${continuationCount + 1}`;
5138
+ const selfUrl = `http://${request.headers.host ?? "localhost"}${pathname}?continue=${encodeURIComponent(convId)}&continuation=${continuationCount + 1}`;
5055
5139
  try {
5056
5140
  const selfRes = await fetch(selfUrl, {
5057
5141
  method: "GET",
@@ -5061,7 +5145,7 @@ export const createRequestHandler = async (options?: {
5061
5145
  });
5062
5146
  const selfBody = await selfRes.json() as Record<string, unknown>;
5063
5147
  writeJson(response, 200, {
5064
- conversationId: conversation.conversationId,
5148
+ conversationId: convId,
5065
5149
  status: "continued",
5066
5150
  continuations: continuationCount + 1,
5067
5151
  finalResult: selfBody,
@@ -5069,7 +5153,7 @@ export const createRequestHandler = async (options?: {
5069
5153
  });
5070
5154
  } catch (continueError) {
5071
5155
  writeJson(response, 200, {
5072
- conversationId: conversation.conversationId,
5156
+ conversationId: convId,
5073
5157
  status: "continuation_failed",
5074
5158
  error: continueError instanceof Error ? continueError.message : "Unknown error",
5075
5159
  duration: Date.now() - start,
@@ -5080,12 +5164,28 @@ export const createRequestHandler = async (options?: {
5080
5164
  }
5081
5165
 
5082
5166
  writeJson(response, 200, {
5083
- conversationId: conversation.conversationId,
5167
+ conversationId: convId,
5084
5168
  status: runResult.status,
5085
5169
  response: assistantResponse.slice(0, 500),
5086
5170
  duration: Date.now() - start,
5087
5171
  steps: runResult.steps,
5088
5172
  });
5173
+ } finally {
5174
+ activeConversationRuns.delete(convId);
5175
+ const hadDeferred = pendingCallbackNeeded.delete(convId);
5176
+ const checkConv = await conversationStore.get(convId);
5177
+ if (hadDeferred || checkConv?.pendingSubagentResults?.length) {
5178
+ if (isServerless) {
5179
+ selfFetchWithRetry(`/api/internal/conversations/${encodeURIComponent(convId)}/subagent-callback`).catch(err =>
5180
+ console.error(`[cron] subagent callback self-fetch failed:`, err instanceof Error ? err.message : err),
5181
+ );
5182
+ } else {
5183
+ processSubagentCallback(convId, true).catch(err =>
5184
+ console.error(`[cron] subagent callback failed:`, err instanceof Error ? err.message : err),
5185
+ );
5186
+ }
5187
+ }
5188
+ }
5089
5189
  } catch (error) {
5090
5190
  writeJson(response, 500, {
5091
5191
  code: "CRON_RUN_ERROR",
@@ -5101,6 +5201,11 @@ export const createRequestHandler = async (options?: {
5101
5201
  handler._cronJobs = cronJobs;
5102
5202
  handler._conversationStore = conversationStore;
5103
5203
  handler._messagingAdapters = messagingAdapters;
5204
+ handler._activeConversationRuns = activeConversationRuns;
5205
+ handler._pendingCallbackNeeded = pendingCallbackNeeded;
5206
+ handler._processSubagentCallback = processSubagentCallback;
5207
+ handler._broadcastEvent = broadcastEvent;
5208
+ handler._finishConversationStream = finishConversationStream;
5104
5209
 
5105
5210
  // Recover stale subagent runs that were "running" when the server last stopped
5106
5211
  // or that have been inactive longer than the staleness threshold.
@@ -5169,6 +5274,8 @@ export const startDevServer = async (
5169
5274
  steps: number;
5170
5275
  assistantMetadata?: Message["metadata"];
5171
5276
  hasContent: boolean;
5277
+ contextTokens: number;
5278
+ contextWindow: number;
5172
5279
  };
5173
5280
 
5174
5281
  const runCronAgent = async (
@@ -5176,9 +5283,12 @@ export const startDevServer = async (
5176
5283
  task: string,
5177
5284
  conversationId: string,
5178
5285
  historyMessages: Message[],
5286
+ onEvent?: (event: AgentEvent) => void,
5179
5287
  ): Promise<CronRunResult> => {
5180
5288
  let assistantResponse = "";
5181
5289
  let steps = 0;
5290
+ let contextTokens = 0;
5291
+ let contextWindow = 0;
5182
5292
  const toolTimeline: string[] = [];
5183
5293
  const sections: Array<{ type: "text" | "tools"; content: string | string[] }> = [];
5184
5294
  let currentTools: string[] = [];
@@ -5189,6 +5299,7 @@ export const startDevServer = async (
5189
5299
  parameters: { __activeConversationId: conversationId },
5190
5300
  messages: historyMessages,
5191
5301
  })) {
5302
+ onEvent?.(event);
5192
5303
  if (event.type === "model:chunk") {
5193
5304
  if (currentTools.length > 0) {
5194
5305
  sections.push({ type: "tools", content: currentTools });
@@ -5221,6 +5332,8 @@ export const startDevServer = async (
5221
5332
  }
5222
5333
  if (event.type === "run:completed") {
5223
5334
  steps = event.result.steps;
5335
+ contextTokens = event.result.contextTokens ?? 0;
5336
+ contextWindow = event.result.contextWindow ?? 0;
5224
5337
  if (!assistantResponse && event.result.response) {
5225
5338
  assistantResponse = event.result.response;
5226
5339
  }
@@ -5240,7 +5353,7 @@ export const startDevServer = async (
5240
5353
  sections: sections.length > 0 ? sections : undefined,
5241
5354
  } as Message["metadata"])
5242
5355
  : undefined;
5243
- return { response: assistantResponse, steps, assistantMetadata, hasContent };
5356
+ return { response: assistantResponse, steps, assistantMetadata, hasContent, contextTokens, contextWindow };
5244
5357
  };
5245
5358
 
5246
5359
  const buildCronMessages = (
@@ -5267,6 +5380,9 @@ export const startDevServer = async (
5267
5380
  const harnessRef = handler._harness;
5268
5381
  const store = handler._conversationStore;
5269
5382
  const adapters = handler._messagingAdapters;
5383
+ const activeRuns = handler._activeConversationRuns;
5384
+ const deferredCallbacks = handler._pendingCallbackNeeded;
5385
+ const runCallback = handler._processSubagentCallback;
5270
5386
  if (!harnessRef || !store) return;
5271
5387
 
5272
5388
  for (const [jobName, config] of entries) {
@@ -5308,32 +5424,56 @@ export const startDevServer = async (
5308
5424
 
5309
5425
  const task = `[Scheduled: ${jobName}]\n${config.task}`;
5310
5426
  const historyMessages = [...conversation.messages];
5427
+ const convId = conversation.conversationId;
5311
5428
 
5429
+ activeRuns?.set(convId, {
5430
+ ownerId: "local-owner",
5431
+ abortController: new AbortController(),
5432
+ runId: null,
5433
+ });
5312
5434
  try {
5313
- const result = await runCronAgent(harnessRef, task, conversation.conversationId, historyMessages);
5314
-
5315
- conversation.messages = buildCronMessages(task, historyMessages, result);
5316
- conversation.updatedAt = Date.now();
5317
- await store.update(conversation);
5318
-
5319
- if (result.response) {
5320
- try {
5321
- await adapter.sendReply(
5322
- {
5323
- channelId: chatId,
5324
- platformThreadId: conversation.channelMeta?.platformThreadId ?? chatId,
5325
- },
5326
- result.response,
5327
- );
5328
- } catch (sendError) {
5329
- const sendMsg = sendError instanceof Error ? sendError.message : String(sendError);
5330
- process.stderr.write(`[cron] ${jobName}: send to ${chatId} failed: ${sendMsg}\n`);
5435
+ const broadcastCh = handler._broadcastEvent;
5436
+ const result = await runCronAgent(harnessRef, task, convId, historyMessages,
5437
+ broadcastCh ? (ev) => broadcastCh(convId, ev) : undefined,
5438
+ );
5439
+ handler._finishConversationStream?.(convId);
5440
+
5441
+ const freshConv = await store.get(convId);
5442
+ if (freshConv) {
5443
+ freshConv.messages = buildCronMessages(task, historyMessages, result);
5444
+ if (result.contextTokens > 0) freshConv.contextTokens = result.contextTokens;
5445
+ if (result.contextWindow > 0) freshConv.contextWindow = result.contextWindow;
5446
+ freshConv.updatedAt = Date.now();
5447
+ await store.update(freshConv);
5448
+
5449
+ if (result.response) {
5450
+ try {
5451
+ await adapter.sendReply(
5452
+ {
5453
+ channelId: chatId,
5454
+ platformThreadId: freshConv.channelMeta?.platformThreadId ?? chatId,
5455
+ },
5456
+ result.response,
5457
+ );
5458
+ } catch (sendError) {
5459
+ const sendMsg = sendError instanceof Error ? sendError.message : String(sendError);
5460
+ process.stderr.write(`[cron] ${jobName}: send to ${chatId} failed: ${sendMsg}\n`);
5461
+ }
5331
5462
  }
5332
5463
  }
5333
5464
  totalChats++;
5334
5465
  } catch (runError) {
5335
5466
  const runMsg = runError instanceof Error ? runError.message : String(runError);
5336
5467
  process.stderr.write(`[cron] ${jobName}: run for chat ${chatId} failed: ${runMsg}\n`);
5468
+ } finally {
5469
+ activeRuns?.delete(convId);
5470
+ const hadDeferred = deferredCallbacks?.delete(convId) ?? false;
5471
+ const checkConv = await store.get(convId);
5472
+ if (hadDeferred || checkConv?.pendingSubagentResults?.length) {
5473
+ runCallback?.(convId, true).catch((err: unknown) =>
5474
+ console.error(`[cron] ${jobName}: subagent callback for ${chatId} failed:`, err instanceof Error ? err.message : err),
5475
+ );
5476
+ }
5337
5477
  }
5338
5478
  }
5339
5479
 
@@ -5347,15 +5487,31 @@ export const startDevServer = async (
5347
5487
  return;
5348
5488
  }
5349
5489
 
5490
+ let cronConvId: string | undefined;
5350
5491
  try {
5351
5492
  const conversation = await store.create(
5352
5493
  "local-owner",
5353
5494
  `[cron] ${jobName} ${timestamp}`,
5354
5495
  );
5355
- const result = await runCronAgent(harnessRef, config.task, conversation.conversationId, []);
5356
- conversation.messages = buildCronMessages(config.task, [], result);
5357
- conversation.updatedAt = Date.now();
5358
- await store.update(conversation);
5496
+ cronConvId = conversation.conversationId;
5497
+ activeRuns?.set(cronConvId, {
5498
+ ownerId: "local-owner",
5499
+ abortController: new AbortController(),
5500
+ runId: null,
5501
+ });
5502
+ const broadcast = handler._broadcastEvent;
5503
+ const result = await runCronAgent(harnessRef, config.task, cronConvId, [],
5504
+ broadcast ? (ev) => broadcast(cronConvId!, ev) : undefined,
5505
+ );
5506
+ handler._finishConversationStream?.(cronConvId);
5507
+ const freshConv = await store.get(cronConvId);
5508
+ if (freshConv) {
5509
+ freshConv.messages = buildCronMessages(config.task, [], result);
5510
+ if (result.contextTokens > 0) freshConv.contextTokens = result.contextTokens;
5511
+ if (result.contextWindow > 0) freshConv.contextWindow = result.contextWindow;
5512
+ freshConv.updatedAt = Date.now();
5513
+ await store.update(freshConv);
5514
+ }
5359
5515
  const elapsed = ((Date.now() - start) / 1000).toFixed(1);
5360
5516
  process.stdout.write(
5361
5517
  `[cron] ${jobName} completed in ${elapsed}s (${result.steps} steps)\n`,
@@ -5366,6 +5522,17 @@ export const startDevServer = async (
5366
5522
  process.stderr.write(
5367
5523
  `[cron] ${jobName} failed after ${elapsed}s: ${msg}\n`,
5368
5524
  );
5525
+ } finally {
5526
+ if (cronConvId) {
5527
+ activeRuns?.delete(cronConvId);
5528
+ const hadDeferred = deferredCallbacks?.delete(cronConvId) ?? false;
5529
+ const checkConv = await store.get(cronConvId);
5530
+ if (hadDeferred || checkConv?.pendingSubagentResults?.length) {
5531
+ runCallback?.(cronConvId, true).catch((err: unknown) =>
5532
+ console.error(`[cron] ${jobName}: subagent callback failed:`, err instanceof Error ? err.message : err),
5533
+ );
5534
+ }
5535
+ }
5369
5536
  }
5370
5537
  },
5371
5538
  );