Repository navigation
feat(agent-core-v2): add cross-process session occupancy lock #4246
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6e97fc4
7d5c9e7
d0ebcf0
cdc7f54
d901bdb
60abd1e
b22b7c6
786853c
610f8e3
c4c3e88
45e05a5
84e33ca
325a97d
b9d5e94
b806c3f
9c0d0b9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@moonshot-ai/kimi-code": minor | ||
| --- | ||
|
|
||
| When the same session is open in multiple clients, sending from a second one now shows an occupancy prompt with retry/cancel, or reloads updates made elsewhere first. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,16 +31,23 @@ import { | |
| IEventDispatcher, | ||
| IHostFileSystem, | ||
| IOAuthToolkit, | ||
| ISessionContext, | ||
| ISessionIndex, | ||
| ISessionLockService, | ||
| ISessionManager, | ||
| ITelemetryService, | ||
| IWorkspaceInstanceManager, | ||
| IAppendLogStore, | ||
| PRINT_MAX_TURNS_DEFAULT, | ||
| PRINT_WAIT_CEILING_S_DEFAULT, | ||
| applyPrintModeConfigDefaults, | ||
| beginSessionRun, | ||
| bootstrap, | ||
| closeSessionById, | ||
| createCloudAppender, | ||
| ensureMainAgent, | ||
| ErrorCodes, | ||
| isError2, | ||
| resumeSessionById, | ||
| logSeed, | ||
| parseAgentFileText, | ||
|
|
@@ -50,6 +57,7 @@ import { | |
| resolveLoggingConfig, | ||
| resolvePrintBackgroundMode, | ||
| setClampedTimeout, | ||
| type Error2, | ||
| type Event2, | ||
| type IAgentScopeHandle, | ||
| type ISessionScopeHandle, | ||
|
|
@@ -286,7 +294,7 @@ export async function runV2Print( | |
| // Best-effort: a broken mcp.json or trust store must not fail the run. | ||
| } | ||
|
|
||
| const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr); | ||
| let resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr); | ||
| restorePermission = resolved.restorePermission; | ||
| quiesceAgents = async () => { | ||
| releaseQuiescence = await quiesceSessionAgents(resolved.session, resolved.agent); | ||
|
|
@@ -302,27 +310,69 @@ export async function runV2Print( | |
| } | ||
|
|
||
| const goalCreate = parseHeadlessGoalCreate(opts.prompt!); | ||
| if (goalCreate !== undefined) { | ||
| await runNativeGoal( | ||
| app, | ||
| resolved.session, | ||
| resolved.agent, | ||
| goalCreate, | ||
| resolved.goalModel, | ||
| outputFormat, | ||
| stdout, | ||
| stderr, | ||
| ); | ||
| } else { | ||
| await runNativeTurn( | ||
| app, | ||
| resolved.session, | ||
| resolved.agent, | ||
| opts.prompt!, | ||
| outputFormat, | ||
| stdout, | ||
| stderr, | ||
| const reloadAfterStale = async (): Promise<void> => { | ||
| stderr.write( | ||
| `Session "${resolved.session.id}" was updated by another client; reloading.\n`, | ||
| ); | ||
| await closeSessionById(app.accessor, resolved.session.id); | ||
| const reloaded = await resolveNativeSession(app, opts, workDir, defaultModel, stderr); | ||
| resolved = reloaded; | ||
| restorePermission = reloaded.restorePermission; | ||
| quiesceAgents = async () => { | ||
| releaseQuiescence = await quiesceSessionAgents(reloaded.session, reloaded.agent); | ||
| }; | ||
| flushWires = () => flushSessionWires(reloaded.session, reloaded.agent); | ||
| }; | ||
| const conflictOf = (error: unknown): 'occupied' | 'stale' | undefined => { | ||
| if (!isError2(error)) return undefined; | ||
| if (error.code === ErrorCodes.SESSION_OCCUPIED) return 'occupied'; | ||
| if (error.code === ErrorCodes.SESSION_STALE) return 'stale'; | ||
| return undefined; | ||
| }; | ||
| const runTurnOnce = async (): Promise<void> => { | ||
| if (goalCreate !== undefined) { | ||
| await ensureNativeGoal(resolved.agent, goalCreate, resolved.goalModel); | ||
| await runNativeGoalTurn( | ||
| app, | ||
| resolved.session, | ||
| resolved.agent, | ||
| goalCreate.objective, | ||
| outputFormat, | ||
| stdout, | ||
| stderr, | ||
| ); | ||
| } else { | ||
| await runNativeTurn(app, resolved.session, resolved.agent, opts.prompt!, outputFormat, stdout, stderr); | ||
| } | ||
| }; | ||
| try { | ||
| preflightSessionRun(app, resolved.session); | ||
| } catch (error) { | ||
| if (conflictOf(error) === 'occupied') { | ||
| reportSessionOccupied(error as Error2, outputFormat, stdout, stderr); | ||
| return; | ||
| } | ||
| if (conflictOf(error) === undefined) throw error; | ||
| await reloadAfterStale(); | ||
| } | ||
| try { | ||
| await runTurnOnce(); | ||
| } catch (error) { | ||
| if (conflictOf(error) === 'occupied') { | ||
| reportSessionOccupied(error as Error2, outputFormat, stdout, stderr); | ||
| return; | ||
| } | ||
| if (conflictOf(error) === undefined) throw error; | ||
| await reloadAfterStale(); | ||
| try { | ||
| await runTurnOnce(); | ||
| } catch (retryError) { | ||
| if (conflictOf(retryError) === 'occupied') { | ||
| reportSessionOccupied(retryError as Error2, outputFormat, stdout, stderr); | ||
| return; | ||
| } | ||
| throw retryError; | ||
| } | ||
| } | ||
| writeResumeHint(resolved.session.id, outputFormat, stdout, stderr); | ||
|
|
||
|
|
@@ -637,22 +687,60 @@ async function runNativeTurn( | |
| } | ||
| } | ||
|
|
||
| async function runNativeGoal( | ||
| app: Scope, | ||
| session: ISessionScopeHandle, | ||
| agent: IAgentScopeHandle, | ||
| goal: HeadlessGoalCreate, | ||
| model: string | undefined, | ||
| const PRINT_EXIT_SESSION_OCCUPIED = 4; | ||
|
|
||
| function reportSessionOccupied( | ||
| error: Error2, | ||
| outputFormat: PromptOutputFormat, | ||
| stdout: PromptOutput, | ||
| stderr: PromptOutput, | ||
| ): void { | ||
| const message = error.message; | ||
| if (outputFormat === 'stream-json') { | ||
| stdout.write( | ||
| `${JSON.stringify({ type: 'error', code: 'session.occupied', message, details: error.details })}\n`, | ||
| ); | ||
| } else { | ||
| stderr.write(`${message}\n`); | ||
| } | ||
| process.exitCode = PRINT_EXIT_SESSION_OCCUPIED; | ||
| } | ||
|
|
||
| function preflightSessionRun(app: Scope, session: ISessionScopeHandle): void { | ||
| const ctx = session.accessor.get(ISessionContext); | ||
| beginSessionRun( | ||
| app.accessor.get(ISessionLockService), | ||
| app.accessor.get(IAppendLogStore), | ||
| ctx, | ||
| session.accessor.get(IAgentLifecycleService), | ||
| ); | ||
| } | ||
|
|
||
| async function ensureNativeGoal( | ||
| agent: IAgentScopeHandle, | ||
| goal: HeadlessGoalCreate, | ||
| model: string | undefined, | ||
| ): Promise<void> { | ||
| requireConfiguredModel(model); | ||
| const goalService = agent.accessor.get(IAgentGoalService); | ||
| const existing = goalService.getGoal().goal; | ||
| if (existing?.status === 'active' && existing.objective === goal.objective) return; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a resumed print session that already has an active goal AGENTS.md reference: AGENTS.md:L102-L104 Useful? React with 👍 / 👎. |
||
| await goalService.createGoal({ | ||
| objective: goal.objective, | ||
| replace: goal.replace, | ||
| }); | ||
| } | ||
|
|
||
| async function runNativeGoalTurn( | ||
| app: Scope, | ||
| session: ISessionScopeHandle, | ||
| agent: IAgentScopeHandle, | ||
| objective: string, | ||
| outputFormat: PromptOutputFormat, | ||
| stdout: PromptOutput, | ||
| stderr: PromptOutput, | ||
| ): Promise<void> { | ||
| const goalService = agent.accessor.get(IAgentGoalService); | ||
| let completedSnapshot: { readonly status: string } | null = null; | ||
| const subscription = agent.accessor.get(IEventBus).subscribe((event: Event2<any>) => { | ||
| if (event.type === 'goal.updated') { | ||
|
|
@@ -662,18 +750,24 @@ async function runNativeGoal( | |
| } | ||
| } | ||
| }); | ||
| let turnError: unknown; | ||
| try { | ||
| await runNativeTurn(app, session, agent, goal.objective, outputFormat, stdout, stderr); | ||
| await runNativeTurn(app, session, agent, objective, outputFormat, stdout, stderr); | ||
| } catch (error) { | ||
| turnError = error; | ||
| throw error; | ||
| } finally { | ||
| subscription.dispose(); | ||
| const snapshot = completedSnapshot ?? goalService.getGoal().goal; | ||
| if (outputFormat === 'stream-json') { | ||
| stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`); | ||
| } else { | ||
| stderr.write(`${formatGoalSummaryText(snapshot)}\n`); | ||
| } | ||
| if (snapshot !== null && snapshot.status !== 'complete') { | ||
| process.exitCode = goalExitCode(snapshot.status); | ||
| if (!isError2(turnError) || turnError.code !== ErrorCodes.SESSION_STALE) { | ||
| const snapshot = completedSnapshot ?? goalService.getGoal().goal; | ||
| if (outputFormat === 'stream-json') { | ||
| stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`); | ||
| } else { | ||
| stderr.write(`${formatGoalSummaryText(snapshot)}\n`); | ||
| } | ||
| if (snapshot !== null && snapshot.status !== 'complete') { | ||
| process.exitCode = goalExitCode(snapshot.status); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import { | ||
| Container, | ||
| matchesKey, | ||
| Key, | ||
| truncateToWidth, | ||
| visibleWidth, | ||
| type Focusable, | ||
| } from '@moonshot-ai/pi-tui'; | ||
| import { SELECT_POINTER } from '#/tui/constant/symbols'; | ||
| import { currentTheme } from '#/tui/theme'; | ||
| import { SearchableList } from '#/tui/utils/searchable-list'; | ||
|
|
||
| export type SessionOccupiedAction = 'retry' | 'cancel'; | ||
|
|
||
| interface SessionOccupiedOption { | ||
| readonly value: SessionOccupiedAction; | ||
| readonly label: string; | ||
| readonly description?: string; | ||
| } | ||
|
|
||
| const OPTIONS: readonly SessionOccupiedOption[] = [ | ||
| { value: 'retry', label: 'Retry', description: 'send again now' }, | ||
| { value: 'cancel', label: 'Cancel', description: 'get your message back' }, | ||
| ]; | ||
|
|
||
| export interface SessionOccupiedDialogOptions { | ||
| readonly message: string; | ||
| readonly onSelect: (action: SessionOccupiedAction) => void; | ||
| readonly onCancel: () => void; | ||
| } | ||
|
|
||
| export class SessionOccupiedDialogComponent extends Container implements Focusable { | ||
| focused = false; | ||
| private readonly opts: SessionOccupiedDialogOptions; | ||
| private readonly list: SearchableList<SessionOccupiedOption>; | ||
|
|
||
| constructor(opts: SessionOccupiedDialogOptions) { | ||
| super(); | ||
| this.opts = opts; | ||
| this.list = new SearchableList({ | ||
| items: OPTIONS, | ||
| toSearchText: (o) => o.label, | ||
| initialIndex: 0, | ||
| searchable: false, | ||
| }); | ||
| } | ||
|
|
||
| handleInput(data: string): void { | ||
| if (matchesKey(data, Key.escape)) { | ||
| this.opts.onCancel(); | ||
| return; | ||
| } | ||
| if (matchesKey(data, Key.enter)) { | ||
| const chosen = this.list.selected(); | ||
| if (chosen !== undefined) this.opts.onSelect(chosen.value); | ||
| return; | ||
| } | ||
| this.list.handleKey(data); | ||
| } | ||
|
|
||
| override render(width: number): string[] { | ||
| const view = this.list.view(); | ||
| const lines: string[] = [ | ||
| currentTheme.fg('primary', '─'.repeat(width)), | ||
| currentTheme.boldFg('primary', ` ${this.opts.message}`), | ||
| currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc cancel'), | ||
| '', | ||
| ]; | ||
|
|
||
| const maxLabelWidth = Math.max(...OPTIONS.map((o) => visibleWidth(o.label))); | ||
| for (let i = view.page.start; i < view.page.end; i++) { | ||
| const opt = view.items[i]!; | ||
| const isSelected = i === view.selectedIndex; | ||
| const pointer = isSelected ? SELECT_POINTER : ' '; | ||
| let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); | ||
| line += isSelected | ||
| ? currentTheme.boldFg('primary', opt.label) | ||
| : currentTheme.fg('text', opt.label); | ||
| if (opt.description !== undefined) { | ||
| const gap = maxLabelWidth - visibleWidth(opt.label) + 4; | ||
| line += ' '.repeat(gap) + currentTheme.fg('textMuted', opt.description); | ||
| } | ||
| lines.push(line); | ||
| } | ||
|
|
||
| lines.push(''); | ||
| lines.push(currentTheme.fg('primary', '─'.repeat(width))); | ||
| return lines.map((line) => truncateToWidth(line, width)); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a resumed print-mode session hits
session.stale,resolveNativeSessionhas already changed its permission mode toautoand saved a closure that restores the previous mode. This closes the old scope without invoking that closure, then replacesrestorePermissionwith the reloaded scope's closure; because closing drains the old wire, the reload observesautoas its previous mode. A one-off headless run can therefore silently leave future interactive clients inautomode. Restore the original override before closing, or perform stale preflight before applying the temporary permission change.AGENTS.md reference: AGENTS.md:L122-L124
Useful? React with 👍 / 👎.