march-cli 0.1.41 → 0.1.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "march-cli",
3
- "version": "0.1.41",
3
+ "version": "0.1.42",
4
4
  "description": "March CLI — terminal-native coding agent with context reconstruction",
5
5
  "type": "module",
6
6
  "main": "./src/main.mjs",
@@ -18,7 +18,7 @@ import { resolveRunnerSessionOptions } from "./session/session-options.mjs";
18
18
  import { createSessionBinding } from "./session/session-binding.mjs";
19
19
  import { maybeAutoNameSession } from "./session/session-auto-name.mjs";
20
20
  import { MARCH_BASE_TOOL_NAMES } from "./tool-names.mjs";
21
- import { runRunnerTurn } from "./turn/turn-runner.mjs";
21
+ import { MODEL_STREAM_IDLE_TIMEOUT_CODE, runRunnerTurn } from "./turn/turn-runner.mjs";
22
22
  import { beginLoggedTurn } from "./turn/turn-logging.mjs";
23
23
  import { appendFastVariants, createFastModelEntry, fromFastEntryModel, isFastProvider } from "./runner/fast-model.mjs";
24
24
  import { registerSuperGrokProvider } from "../supergrok/provider.mjs";
@@ -142,6 +142,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
142
142
  turnLog.endSuccess(result);
143
143
  return result;
144
144
  } catch (err) {
145
+ if (err?.code === MODEL_STREAM_IDLE_TIMEOUT_CODE) nextTurnContextMode = "continueExistingPiTranscript";
145
146
  notifyTurnEndDetached(turnNotifier, {
146
147
  status: "error",
147
148
  sessionName: engine.sessionName,
@@ -12,6 +12,8 @@ export function createTurnEventState() {
12
12
  assistantContextParts: [],
13
13
  activeToolContextPart: null,
14
14
  toolCalls: [],
15
+ lastAssistantStopReason: null,
16
+ lastAssistantErrorMessage: null,
15
17
  };
16
18
  }
17
19
 
@@ -19,6 +21,10 @@ export function handleRunnerSessionEvent(event, { ui, engine, state }) {
19
21
  if (event.type === "message_update" && event.assistantMessageEvent) {
20
22
  handleAssistantMessageEvent(event.assistantMessageEvent, { ui, state });
21
23
  }
24
+ if (event.type === "message_end" && event.message?.role === "assistant") {
25
+ state.lastAssistantStopReason = event.message.stopReason ?? null;
26
+ state.lastAssistantErrorMessage = event.message.errorMessage ?? null;
27
+ }
22
28
  if (event.type === "tool_execution_start") {
23
29
  closeAssistantReply({ ui, state });
24
30
  appendToolStartContext(state, event.toolName, event.args);
@@ -2,6 +2,8 @@ import { formatRecallHints } from "../../memory/markdown-store.mjs";
2
2
  import { resolveImageAttachmentReferences } from "../../session/attachment-references.mjs";
3
3
  import { closeAssistantReply, compactAssistantContext, createTurnEventState, handleRunnerSessionEvent } from "./turn-events.mjs";
4
4
 
5
+ export const MODEL_STREAM_IDLE_TIMEOUT_CODE = "MODEL_STREAM_IDLE_TIMEOUT";
6
+
5
7
  export async function runRunnerTurn({
6
8
  prompt,
7
9
  userMessage,
@@ -26,17 +28,33 @@ export async function runRunnerTurn({
26
28
  const activeSession = sessionBinding.get();
27
29
  const turnState = createTurnEventState();
28
30
  const midTurnRecallHints = [];
31
+ const idleWatchdog = createModelStreamIdleWatchdog({ session: activeSession, logger, setPhase });
29
32
  ui.turnStart();
30
33
  setPhase?.("subscribed");
31
34
  logger?.event("turn.ui.start");
32
35
 
33
36
  const unsubscribe = activeSession.subscribe((event) => {
34
37
  logSessionEvent(logger, event);
35
- if (event.type === "tool_execution_start") setPhase?.(`tool_running:${event.toolName ?? "unknown"}`);
36
- if (event.type === "tool_execution_end") setPhase?.("model_streaming");
37
- if (event.type === "auto_retry_start") setPhase?.("retry_wait");
38
- if (event.type === "auto_retry_end") setPhase?.("model_streaming");
39
- if (event.type === "message_update") setPhase?.("model_streaming");
38
+ if (event.type === "tool_execution_start") {
39
+ idleWatchdog.pause();
40
+ setPhase?.(`tool_running:${event.toolName ?? "unknown"}`);
41
+ }
42
+ if (event.type === "tool_execution_end") {
43
+ setPhase?.("model_streaming");
44
+ idleWatchdog.arm("tool_execution_end");
45
+ }
46
+ if (event.type === "auto_retry_start") {
47
+ idleWatchdog.pause();
48
+ setPhase?.("retry_wait");
49
+ }
50
+ if (event.type === "auto_retry_end") {
51
+ setPhase?.("model_streaming");
52
+ idleWatchdog.arm("auto_retry_end");
53
+ }
54
+ if (event.type === "message_update") {
55
+ setPhase?.("model_streaming");
56
+ idleWatchdog.arm("message_update");
57
+ }
40
58
  handleRunnerSessionEvent(event, { ui, engine, state: turnState });
41
59
  if (event.type === "tool_execution_start") {
42
60
  const hints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
@@ -59,11 +77,14 @@ export async function runRunnerTurn({
59
77
  logger?.event("model.prompt.start", { contextMode });
60
78
  try {
61
79
  if (contextMode === "rebuild") resetPiMessageHistory(activeSession);
62
- await activeSession.prompt(
80
+ idleWatchdog.arm("model_request");
81
+ await idleWatchdog.watch(activeSession.prompt(
63
82
  contextMode === "continueExistingPiTranscript" ? (userMessage ?? prompt) : prompt,
64
83
  attachmentReferences.images.length > 0 ? { images: attachmentReferences.images } : undefined,
65
- );
84
+ ));
85
+ throwIfAssistantEndedWithError(turnState);
66
86
  } finally {
87
+ idleWatchdog.clear();
67
88
  setModelCallKind("model");
68
89
  logger?.event("model.prompt.end");
69
90
  }
@@ -86,11 +107,70 @@ export async function runRunnerTurn({
86
107
  return { draft: turnState.draft };
87
108
  } finally {
88
109
  logger?.event("turn.ui.end");
110
+ idleWatchdog.clear();
89
111
  ui.turnEnd();
90
112
  unsubscribe();
91
113
  }
92
114
  }
93
115
 
116
+ function createModelStreamIdleWatchdog({ session, logger, setPhase }) {
117
+ const timeoutMs = getModelStreamIdleTimeoutMs();
118
+ let timer = null;
119
+ let timedOut = false;
120
+ let rejectIdle = null;
121
+ const idlePromise = new Promise((_, reject) => {
122
+ rejectIdle = reject;
123
+ });
124
+
125
+ return {
126
+ arm(reason) {
127
+ if (timeoutMs <= 0 || timedOut) return;
128
+ clearTimer();
129
+ timer = setTimeout(() => {
130
+ timedOut = true;
131
+ setPhase?.("model_idle_timeout");
132
+ logger?.event("model.stream.idle_timeout", { timeoutMs, reason });
133
+ try { session.abortRetry?.(); } catch {}
134
+ try { session.abort?.(); } catch {}
135
+ rejectIdle(createModelStreamIdleTimeoutError(timeoutMs, reason));
136
+ }, timeoutMs);
137
+ },
138
+ pause: clearTimer,
139
+ clear: clearTimer,
140
+ async watch(promise) {
141
+ const guarded = Promise.resolve(promise);
142
+ guarded.catch(() => {});
143
+ return await Promise.race([guarded, idlePromise]);
144
+ },
145
+ };
146
+
147
+ function clearTimer() {
148
+ if (!timer) return;
149
+ clearTimeout(timer);
150
+ timer = null;
151
+ }
152
+ }
153
+
154
+ function createModelStreamIdleTimeoutError(timeoutMs, reason) {
155
+ const error = new Error(`Model stream idle timeout after ${timeoutMs}ms (${reason}); aborted the current turn`);
156
+ error.code = MODEL_STREAM_IDLE_TIMEOUT_CODE;
157
+ return error;
158
+ }
159
+
160
+ function getModelStreamIdleTimeoutMs() {
161
+ const raw = process.env.MARCH_MODEL_STREAM_IDLE_TIMEOUT_MS;
162
+ if (raw === "0" || raw === "false" || raw === "no") return 0;
163
+ const parsed = raw ? Number.parseInt(raw, 10) : 18000;
164
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 18000;
165
+ }
166
+
167
+ function throwIfAssistantEndedWithError(turnState) {
168
+ if (turnState.lastAssistantStopReason !== "error") return;
169
+ const error = new Error(turnState.lastAssistantErrorMessage || "Model provider returned an error");
170
+ error.code = "MODEL_PROVIDER_ERROR";
171
+ throw error;
172
+ }
173
+
94
174
  function queueMidTurnRecallHints(session, hints, logger) {
95
175
  const content = formatRecallHints("assistant", hints);
96
176
  if (!content) return;