Skip to content

Commit 6e062ae

Browse files
authored
fix(exec): explain Session workspace mismatches instead of suggesting a retry (#584)
mcode exec appended "Retry after the connection recovers." to every runtime failure, including Session errors that can never succeed on retry. A resumed Session whose workspace differs from --cwd now names the Session workspace and the --cwd to use; archived Sessions and a missing --continue target also say what to do next. These errors are marked non-retryable, keep exit code 4 and category runtime, and failed results with retryable: false no longer get the retry hint. Document in the Python SDK README and resume_thread docstrings that a resumed Session must use the cwd it was created with.
1 parent a5a05c2 commit 6e062ae

7 files changed

Lines changed: 102 additions & 11 deletions

File tree

‎packages/code-sdk-python/README.md‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,26 @@ async def main() -> None:
4040
# Same Session, next Turn.
4141
turn = await thread.run("Now list its top-level directories.")
4242

43-
# Persist thread.id and continue later.
44-
later = client.resume_thread(thread.id)
43+
# Persist thread.id and continue later, in the same workspace.
44+
later = client.resume_thread(thread.id, ThreadOptions(cwd="."))
4545
print((await later.run("What did I ask first?")).final_response)
4646

4747

4848
asyncio.run(main())
4949
```
5050

51+
### Resuming a Session
52+
53+
A Session belongs to the workspace it was created in. Pass the same `cwd` to `resume_thread()`;
54+
when `cwd` is omitted, the current working directory is used. A different workspace fails with
55+
`MCodeRunError` ("Session ... belongs to workspace ..."), and retrying does not help.
56+
57+
```python
58+
thread = client.start_thread(ThreadOptions(cwd="/path/to/project"))
59+
await thread.run("...")
60+
later = client.resume_thread(thread.id, ThreadOptions(cwd="/path/to/project"))
61+
```
62+
5163
### Streaming
5264

5365
```python

‎packages/code-sdk-python/src/minimax_code/client.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ def start_thread(self, options: ThreadOptions | None = None) -> Thread:
3333
return Thread(self._host, options, None)
3434

3535
def resume_thread(self, thread_id: str, options: ThreadOptions | None = None) -> Thread:
36-
"""Continues an existing Session, for example one persisted from ``thread.id``."""
36+
"""Continues an existing Session, for example one persisted from ``thread.id``.
37+
38+
Pass the same ``ThreadOptions.cwd`` the Session was created with. When ``cwd`` is omitted
39+
the current working directory is used, and the CLI rejects a Session whose workspace
40+
differs (``MCodeRunError`` with "belongs to workspace ...").
41+
"""
3742
if not isinstance(thread_id, str) or not thread_id.strip():
3843
raise MCodeInvocationError("resume_thread requires a non-empty Session id.")
3944
options = options or ThreadOptions()

‎packages/code-sdk-python/src/minimax_code/sync.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ def start_thread(self, options: ThreadOptions | None = None) -> SyncThread:
4848
return SyncThread(self._loop, self._client.start_thread(options))
4949

5050
def resume_thread(self, thread_id: str, options: ThreadOptions | None = None) -> SyncThread:
51+
"""Continues an existing Session; pass the ``cwd`` it was created with (see
52+
``MCode.resume_thread``)."""
5153
return SyncThread(self._loop, self._client.resume_thread(thread_id, options))
5254

5355
def close(self) -> None:

‎packages/tui/src/headless/error-presentation.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export function formatTuiExecFailure(error: TuiExecError): string {
2020
}
2121
if (error.kind === 'runtime') {
2222
if (isOutputLimitExhaustedFailure(error.message)) return OUTPUT_LIMIT_EXHAUSTED_GUIDANCE;
23+
if (!error.retryable) return asSentence(`The run failed: ${tuiErrorDiagnostic(error)}`);
2324
return `The run failed: ${tuiErrorDiagnostic(error)}. Retry after the connection recovers.`;
2425
}
2526
return `MCode encountered an internal error: ${tuiErrorDiagnostic(error)}. Retry; if it keeps happening, report it through an available support channel.`;
@@ -43,5 +44,10 @@ export function formatTuiExecResultFailure(result: ExecResult): string {
4344
}
4445
if (isOutputLimitExhaustedFailure(result.error?.message)) return OUTPUT_LIMIT_EXHAUSTED_GUIDANCE;
4546
const reason = result.error?.message ? `: ${tuiErrorDiagnostic(result.error.message)}` : '';
47+
if (result.error?.retryable === false) return asSentence(`The run failed${reason}`);
4648
return `The run failed${reason}. Retry after the connection recovers.`;
4749
}
50+
51+
function asSentence(value: string): string {
52+
return /[.!?…]$/u.test(value) ? value : `${value}.`;
53+
}

‎packages/tui/src/headless/exit-policy.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,16 @@ export type TuiExecErrorKind = keyof Pick<
1818
'invocation' | 'config' | 'runtime' | 'internal' | 'cancelled' | 'brokenPipe'
1919
>;
2020

21+
export interface TuiExecErrorOptions extends ErrorOptions {
22+
/** Defaults to true for runtime errors. Set false when retrying cannot succeed. */
23+
readonly retryable?: boolean;
24+
}
25+
2126
export class TuiExecError extends TuiFailure {
2227
constructor(
2328
public readonly kind: TuiExecErrorKind,
2429
message: string,
25-
options?: ErrorOptions,
30+
options?: TuiExecErrorOptions,
2631
) {
2732
super(kind, message, {
2833
code: `exec.${kind}`,

‎packages/tui/src/headless/runner.ts‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -898,7 +898,8 @@ async function resolveSession(
898898
if (!session) {
899899
throw new TuiExecError(
900900
"runtime",
901-
`No active Session exists for workspace ${input.workspaceDir}.`,
901+
`No active Session exists for workspace ${input.workspaceDir}; omit --continue to start a new Session.`,
902+
{ retryable: false },
902903
);
903904
}
904905
assertSessionUsable(session, input.workspaceDir);
@@ -946,16 +947,23 @@ function assertSessionUsable(session: TuiSession, workspaceDir: string): void {
946947
if (session.archived) {
947948
throw new TuiExecError(
948949
"runtime",
949-
`Session is archived: ${session.sessionId}`,
950+
`Session ${session.sessionId} is archived; unarchive it in MCode or start a new Session.`,
951+
{ retryable: false },
950952
);
951953
}
952-
if (
953-
!session.workspaceDir ||
954-
!sameWorkspace(session.workspaceDir, workspaceDir)
955-
) {
954+
if (!session.workspaceDir) {
956955
throw new TuiExecError(
957956
"runtime",
958-
`Session workspace does not match --cwd: ${session.sessionId}`,
957+
`Session ${session.sessionId} has no recorded workspace and cannot be continued.`,
958+
{ retryable: false },
959+
);
960+
}
961+
if (!sameWorkspace(session.workspaceDir, workspaceDir)) {
962+
// Retrying cannot succeed: a Session only continues in the workspace it was created in.
963+
throw new TuiExecError(
964+
"runtime",
965+
`Session ${session.sessionId} belongs to workspace ${session.workspaceDir}, not --cwd ${workspaceDir}; rerun with --cwd ${session.workspaceDir}.`,
966+
{ retryable: false },
959967
);
960968
}
961969
}

‎packages/tui/test/unit/headless-runner.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { join } from 'node:path';
55

66
import { describe, expect, it, vi } from 'vitest';
77

8+
import { formatTuiExecResultFailure } from '../../src/headless/error-presentation.js';
89
import { runTuiExec } from '../../src/headless/runner.js';
910
import type { TuiMessage, TuiStreamEvent } from '../../src/runtime/stream-events.js';
1011

@@ -1316,3 +1317,55 @@ describe('runTuiExec', () => {
13161317
expect(runtime.sendMessage).not.toHaveBeenCalled();
13171318
});
13181319
});
1320+
1321+
describe('runTuiExec Session guidance', () => {
1322+
async function run(input: { sessionId?: string; continueSession?: boolean }, runtime: ReturnType<typeof runtimeWith>) {
1323+
const stderr = vi.fn();
1324+
const code = await runTuiExec(
1325+
{ prompt: 'Fix it', workspaceDir: '/tmp/workspace', version: '0.1.0', ...input },
1326+
{ runtime, stdout: vi.fn(), stderr, shutdown: async () => undefined },
1327+
);
1328+
return { code, stderr: stderr.mock.calls.map(([line]) => line).join('') };
1329+
}
1330+
1331+
it('names the Session workspace on a --cwd mismatch instead of suggesting a retry', async () => {
1332+
const runtime = runtimeWith();
1333+
runtime.getSession.mockResolvedValue({ sessionId: 'session-1', workspaceDir: '/tmp/other', archived: false } as never);
1334+
1335+
const { code, stderr } = await run({ sessionId: 'session-1' }, runtime);
1336+
1337+
expect(code).toBe(4);
1338+
expect(stderr).toBe(
1339+
'mcode exec failed: The run failed: Session session-1 belongs to workspace /tmp/other, not --cwd /tmp/workspace; rerun with --cwd /tmp/other.\n',
1340+
);
1341+
expect(runtime.sendMessage).not.toHaveBeenCalled();
1342+
});
1343+
1344+
it('does not suggest a retry for archived Sessions or a missing --continue target', async () => {
1345+
const archived = runtimeWith();
1346+
archived.getSession.mockResolvedValue({ sessionId: 'session-1', workspaceDir: '/tmp/workspace', archived: true } as never);
1347+
const missing = runtimeWith();
1348+
1349+
const results = [await run({ sessionId: 'session-1' }, archived), await run({ continueSession: true }, missing)];
1350+
1351+
expect(results.map((result) => result.code)).toEqual([4, 4]);
1352+
expect(results[0]?.stderr).toBe(
1353+
'mcode exec failed: The run failed: Session session-1 is archived; unarchive it in MCode or start a new Session.\n',
1354+
);
1355+
expect(results[1]?.stderr).toBe(
1356+
'mcode exec failed: The run failed: No active Session exists for workspace /tmp/workspace; omit --continue to start a new Session.\n',
1357+
);
1358+
for (const result of results) expect(result.stderr).not.toContain('Retry');
1359+
});
1360+
1361+
it('keeps the retry hint only for retryable failed results', () => {
1362+
const failed = (retryable: boolean | undefined) => formatTuiExecResultFailure({
1363+
status: 'failed',
1364+
error: { category: 'runtime', message: 'Upstream disconnected', ...(retryable === undefined ? {} : { retryable }) },
1365+
} as never);
1366+
1367+
expect(failed(true)).toBe('The run failed: Upstream disconnected. Retry after the connection recovers.');
1368+
expect(failed(undefined)).toBe('The run failed: Upstream disconnected. Retry after the connection recovers.');
1369+
expect(failed(false)).toBe('The run failed: Upstream disconnected.');
1370+
});
1371+
});

0 commit comments

Comments
 (0)