Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
99e7984
fix(coding-agent): run resume budget check before session teardown
Tinycute00 Sep 8, 2026
0b85a15
fix(coding-agent): check resume budget against the restored model bef…
Tinycute00 Sep 8, 2026
165ad7e
Merge remote-tracking branch 'upstream/main' into fix/resume-budget-p…
Sep 8, 2026
b5156dd
fix(coding-agent): admit prepared resumes without disrupting live ses…
beer501102-dot Sep 8, 2026
7c3fa75
Merge upstream/main into fix/resume-budget-preflight
beer501102-dot Sep 8, 2026
43b6f8f
fix(sessions): release discarded resume candidate ownership
beer501102-dot Sep 9, 2026
4f7b567
fix(sessions): isolate discarded resume cleanup
beer501102-dot Sep 9, 2026
a7686fd
fix(sessions): preserve staged resume data and recover conflicts
beer501102-dot Sep 9, 2026
8fae1e7
Merge upstream/main into fix/resume-budget-preflight
beer501102-dot Sep 9, 2026
0672e9b
fix(sessions): veto resume before destination preparation
beer501102-dot Sep 9, 2026
36c4da6
fix(tool-search): defer scoped catalog installation until start
beer501102-dot Sep 10, 2026
be41e0d
docs(rpc): align protocol overview with switch ordering
beer501102-dot Sep 10, 2026
bee8f7b
fix(sessions): bound prepared resume fingerprints
beer501102-dot Sep 10, 2026
7a686a0
fix(tool-search): retain the SDK session catalog owner
beer501102-dot Sep 10, 2026
210fb93
docs(rpc): clarify finite resume admission and projections
beer501102-dot Sep 10, 2026
1d438ad
Merge upstream/main into fix/resume-budget-preflight
beer501102-dot Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@

### Fixed

- Keep provider-scoped tool-search catalogs bound to the active session until a resumed session starts, preserving lazy-tool activation and native-request diagnostics after discarded candidates. Direct SDK sessions retain their own catalog and diagnostics without requiring extension startup or taking another session's global ownership ([#1473](https://github.com/code-yeongyu/senpi/pull/1473) by [@Tinycute00](https://github.com/Tinycute00)).
- Resume runs the cancellable before-switch check before destination trust, snapshot and factory preparation, then checks the exact destination SDK budget before outgoing shutdown. Vetoed resumes construct no candidates; cancelled and rejected resumes preserve active `/btw` work and the live session, including cwd-override retries and shared-host RPC. Rejected candidates leave target files unchanged and release their own registrations and tentative writer reservations without shutting down the live service.
- Staged resumes preserve large legacy transcript content through migration, keep the active MCP native-search setting until attachment, and recognize file-URL and tilde aliases for busy self-resumes. Concurrent destination changes are revalidated with bounded content fingerprints before persistence and reported as recoverable errors in the TUI and RPC ([#1473](https://github.com/code-yeongyu/senpi/pull/1473) by [@Tinycute00](https://github.com/Tinycute00)).
- Shared RPC hosts now cut a socket peer that stops reading (a write not accepted within 4 seconds) with an `overflow` record `stalled, resync required` instead of letting it hold the session worker's output credit until the 5-second `session_worker_credit_timeout` quarantined a healthy session mid-turn; a cut or overflowed peer no longer withholds session credit or fails the shared host writer ([#1529](https://github.com/code-yeongyu/senpi/pull/1529)).

### Removed
Expand Down
4 changes: 4 additions & 0 deletions packages/coding-agent/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,10 @@ pi.on("session_info_changed", async (event, ctx) => {

Fired before starting a new session (`/new`) or switching sessions (`/resume`).

This is a cancellable check, not a cleanup event. For `/resume`, it runs before reading the destination snapshot, resolving destination project trust, or constructing its runtime. Returning `{ cancel: true }` prevents all of that destination preparation. Writes completed by an awaited handler are included in the subsequent snapshot.

After the veto accepts, senpi checks the actual destination model, settings, prompt and tools against SDK admission (including mandatory resume compaction when eligible), then acquires the writer grant and synchronously revalidates and persists the candidate. A later admission, missing-cwd, or conflict rejection can therefore follow this event without any replacement. Do not abort side work or release live resources here; `session_shutdown` runs only when an accepted replacement tears down the outgoing session.

```typescript
pi.on("session_before_switch", async (event, ctx) => {
// event.reason - "new" or "resume"
Expand Down
58 changes: 56 additions & 2 deletions packages/coding-agent/docs/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,12 @@ and only then constructs its session writer and runtime. A conflicting alias att
explicitly before opening another writer.

SessionManager writes, switches, forks, new sessions and imports obtain the same grant before writer creation or
append-side normalization. Acquired paths are conservatively retained for that worker's entire lifetime, including
superseded paths after a switch. Each worker may reserve at most 64 paths; an exhausted reservation budget fails
append-side normalization. Resume runs cancellable `session_before_switch` handlers before opening the destination
snapshot and preparing the candidate, which acquires no writer reservation. After exact admission, acceptance obtains
a reversible grant and revalidates the destination before outgoing shutdown; cancellation or failure
releases only a newly acquired candidate grant, leaving existing live ownership intact. Accepted writer paths are
conservatively retained for that worker's entire lifetime, including superseded paths after a switch.
Each worker may reserve at most 64 paths; an exhausted reservation budget fails
explicitly. Close or an opening deadline requests worker termination, but does not release reservations or worker
capacity until the actual exit event. A syscall that cannot yet be interrupted can therefore keep an entry
internally quarantined after the routing handle has closed. `list_sessions` continues to publish `closing`, not a
Expand Down Expand Up @@ -1063,6 +1067,56 @@ If an extension cancelled the switch:
{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": true}}
```

A rejected switch answers with `success: false` plus a typed `errorCode` and structured `errorData`:

| `errorCode` | Meaning | `errorData` |
|---|---|---|
| `missing_session_cwd` | The session's stored cwd no longer exists. Retry the command with `cwdOverride`. | `{"sessionFile", "sessionCwd", "fallbackCwd"}` |
| `session_resume_conflict` | The target changed after the resume snapshot was read. Retry to admit the current bytes. | `{"sessionFile"}` |
| `model_usability_budget` | The stored transcript cannot be admitted by the destination model's resume/compaction policy. | The full budget projection |

When compaction is enabled and the restored context fits the raw model window, a budget shortfall can instead be admitted with `resume_compaction_required`. The first prompt must complete the required compaction and satisfy the remaining budget before a normal provider turn.

The order is: cancellable `session_before_switch` check, destination snapshot and trust/factory preparation, exact SDK budget admission, writer grant with synchronous final revalidation/persistence, then outgoing `session_shutdown` and replacement. A veto prevents destination trust prompts, trust persistence and factory execution. Writes completed by an awaited veto are included in the snapshot; writes during later factory preparation produce a recoverable conflict.

Admission covers the model, prompt and active tools assembled by the destination SDK and factories at that point. `session_start` runs on the accepted replacement; later extension model, tool and prompt changes are not previewed by this check. Startup handler failures are reported through the host's extension-error path, not treated as retroactive resume cancellation. For example, a recommended-model change rejected by its own budget check retains the admitted fallback model. This preflight does not guarantee that arbitrary later configuration changes fit the model window.

Missing-cwd, budget and conflict rejections may follow the cancellable check, but never cause outgoing shutdown or invalidate the current session. Cleanup belongs in `session_shutdown`, not `session_before_switch`; active `/btw` work survives cancelled and rejected admissions. A conflict preserves any intervening target writes, and cancellation or failed admission does not itself write to the target session file. Clients can pick a different session or change the destination configuration and retry. Changing the live model with `set_model` does not override a destination's stored model or a model forced by CLI `--model` / the launch profile; change that startup selection when retrying with a larger model.

```json
{
"type": "response",
"command": "switch_session",
"success": false,
"error": "Model anthropic/claude-opus-4-5 cannot host this session ...",
"errorCode": "model_usability_budget",
"errorData": {
"model": "anthropic/claude-opus-4-5",
"contextWindow": 200000,
"liveContextTokens": 260000,
"systemPromptTokens": 3748,
"activeToolSchemaTokens": 4538,
"outputReserveTokens": 32000,
"compactionReserveTokens": 1024,
"speculationLeadTokens": 0,
"safetyMarginTokens": 16384,
"safetyMarginProfile": "anthropic",
"requiredTokens": 317694,
"shortfallTokens": 117694,
"usable": false,
"admission": "resume"
}
}
```

For a non-empty `switch_session` target, `admission` is `resume` and `speculationLeadTokens` is zero. The shared projection also supports `start` (including empty targets) and `switch` (model changes). For ordinary admission, `requiredTokens` is the sum of `liveContextTokens`, `systemPromptTokens`, `activeToolSchemaTokens`, `outputReserveTokens`, `compactionReserveTokens`, `speculationLeadTokens`, and `safetyMarginTokens`. `shortfallTokens = max(0, requiredTokens - contextWindow)`; `usable` is true exactly when that shortfall is zero.

If that sum exceeds the window during a compaction-enabled resume, the projection also checks two requirements: compaction needs `liveContextTokens + systemPromptTokens + activeToolSchemaTokens + compactionReserveTokens + safetyMarginTokens`; the post-compaction turn needs the effective retained-history allowance plus all the non-history components in the ordinary sum. The retained-history allowance is derived from the compaction settings and model window, not included as a projection field. If both requirements fit, `requiredTokens` becomes their maximum, `shortfallTokens` is zero, and `usable` is true. The original component fields are retained, so their sum need not equal `requiredTokens` in this case.

Separately, the SDK can admit a resume whose projection remains unusable when compaction is enabled and `liveContextTokens <= contextWindow`. Its `resume_compaction_required` event preserves that diagnostic projection, including the positive shortfall; the event does not claim that a normal turn already fits. Required compaction and budget validation must succeed before the first normal provider turn.

`model` and `safetyMarginProfile` identify the selection and margin policy; they are not token components. Treat the returned `requiredTokens` as authoritative rather than recomputing it from the component fields. Clients should distinguish the `resume_compaction_required` event from a failed response and branch on `errorCode`, not parse the human-readable `error` text.

#### fork

Create a new fork from a previous user message on the active branch. Can be cancelled by a `session_before_fork` extension event handler. Returns the text of the message being forked from.
Expand Down
65 changes: 44 additions & 21 deletions packages/coding-agent/src/core/agent-session-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { resolvePath } from "../utils/paths.ts";
import { canonicalizePath, resolvePath } from "../utils/paths.ts";
import type { AgentSession } from "./agent-session.ts";
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts";
import type {
Expand Down Expand Up @@ -77,9 +77,9 @@ function extractUserMessageText(content: string | Array<{ type: string; text?: s
/**
* Owns the current AgentSession plus its cwd-bound services.
*
* Session replacement methods tear down the current runtime first, then create
* and apply the next runtime. If creation fails, the error is propagated to the
* caller. The caller is responsible for user-facing error handling.
* Resume checks the outgoing veto, then prepares and admits the destination
* before tearing down the live runtime. Other replacements create their next
* runtime after teardown. Callers handle propagated errors on their UI surface.
*/
export class AgentSessionRuntime {
private rebindSession?: (session: AgentSession) => Promise<void>;
Expand Down Expand Up @@ -246,25 +246,48 @@ export class AgentSessionRuntime {
projectTrustContextFactory?: (cwd: string) => ProjectTrustContext;
},
): Promise<{ cancelled: boolean }> {
const beforeResult = await this.emitBeforeSwitch("resume", sessionPath);
if (beforeResult.cancelled) {
return beforeResult;
}

const previousSessionFile = this.session.sessionFile;
const sessionManager = SessionManager.open(sessionPath, undefined, options?.cwdOverride);
const isSelfResume =
previousSessionFile !== undefined &&
canonicalizePath(resolvePath(sessionPath)) === canonicalizePath(resolvePath(previousSessionFile));
// Settling active work would append to this same file after taking the candidate snapshot.
if (isSelfResume && this.session.isSessionBusy) return { cancelled: true };
// This is a cancellable check, not cleanup: veto before destination reads,
// trust prompts or factories. Writes completed by the veto belong in the snapshot.
const beforeResult = await this.emitBeforeSwitch("resume", sessionPath);
if (beforeResult.cancelled) return beforeResult;
if (isSelfResume && this.session.isSessionBusy) return { cancelled: true };
const prepared = SessionManager.prepareOpen(sessionPath, undefined, options?.cwdOverride);
Comment thread
Tinycute00 marked this conversation as resolved.
const { sessionManager } = prepared;
assertSessionCwdExists(sessionManager, this.cwd);
await this.teardownCurrent("resume", sessionManager.getSessionFile());
await this.apply(
await this.createRuntime({
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()),
launchProfile: this._launchProfile,
}),
);
// Build and admit the actual destination, including its model selection,
// settings, prompt and tools. Persistence and destructive shutdown stay deferred.
const result = await this.createRuntime({
Comment thread
Tinycute00 marked this conversation as resolved.
Comment thread
Tinycute00 marked this conversation as resolved.
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()),
launchProfile: this._launchProfile,
});
let acceptance: ReturnType<typeof prepared.beginCommit> | undefined;
try {
// Preparation can yield while a new turn starts on the live session.
if (isSelfResume && this.session.isSessionBusy) return { cancelled: true };
// The grant is reversible; final revalidation and persistence do not yield.
acceptance = prepared.beginCommit();
acceptance.commit();
Comment thread
Tinycute00 marked this conversation as resolved.
await this.teardownCurrent("resume", sessionManager.getSessionFile());
Comment thread
Tinycute00 marked this conversation as resolved.
await this.apply(result);
} finally {
if (this.session !== result.session) {
try {
await result.session.disposeCandidate();
} finally {
acceptance?.rollback();
}
}
}
await this.finishSessionReplacement(options?.withSession);
return { cancelled: false };
}
Expand Down
26 changes: 21 additions & 5 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,11 @@ import {
import { WAKE_SOURCE_STATE_EVENT } from "./extensions/builtin/monitor-state-event.ts";
import { CODEX_RESPONSES_API, type ServiceTier } from "./extensions/builtin/service-tier.ts";
import { deriveExtensionRegistrationId } from "./extensions/builtin/tool-search/engine/marker.ts";
import { getToolSearchService } from "./extensions/builtin/tool-search/service.ts";
import {
getToolSearchService,
getToolSearchServiceForActivator,
type ToolSearchService,
} from "./extensions/builtin/tool-search/service.ts";
import {
type ContextUsage,
ExecuteToolError,
Expand Down Expand Up @@ -1195,6 +1199,7 @@ export class AgentSession {
// Tool registry for extension getTools/setTools
private _toolRegistry: Map<string, AgentTool> = new Map();
private _lazyToolActivators: LazyToolActivator[] = [];
private _toolSearchService: ToolSearchService | undefined;
private _toolDefinitions: Map<string, ToolDefinitionEntry> = new Map();
private _toolPromptSnippets: Map<string, string> = new Map();
private _toolPromptGuidelines: Map<string, string[]> = new Map();
Expand Down Expand Up @@ -1452,7 +1457,7 @@ export class AgentSession {
this.agent.resolveUnknownToolCall = (toolName) => {
let service: ReturnType<typeof getToolSearchService>;
try {
service = getToolSearchService();
service = this._toolSearchService ?? getToolSearchService();
} catch {
return undefined;
}
Expand Down Expand Up @@ -2928,8 +2933,10 @@ export class AgentSession {
/**
* Remove all listeners and disconnect from agent.
* Call this when completely done with the session.
* Unstarted resume candidates do not own provider resources keyed by the
* persisted session ID, which may still belong to a live runtime.
*/
dispose(): void {
dispose(options?: { releaseProviderResources?: boolean }): void {
try {
this._probeBackScheduler.cancel("dispose");
this.abortRetry();
Expand All @@ -2951,7 +2958,14 @@ export class AgentSession {
this._unsubscribeWakeSources?.();
this._unsubscribeWakeSources = undefined;
this._eventListeners = [];
cleanupSessionResources(this.sessionId);
if (options?.releaseProviderResources !== false) cleanupSessionResources(this.sessionId);
}

/** Invalidate only an unstarted destination's registrations, not live-session resources. */
async disposeCandidate(): Promise<void> {
// Normal shutdown handlers may mutate process-global state owned by the live session.
// Runner invalidation removes this candidate's tracked subscriptions without dispatching them.
this.dispose({ releaseProviderResources: false });
}

/** Live in-session activity signals; see `session-activity.ts` for the contract. */
Expand Down Expand Up @@ -7142,6 +7156,7 @@ export class AgentSession {
},
registerLazyToolActivator: (activator) => {
this._lazyToolActivators.push(activator);
this._toolSearchService = getToolSearchServiceForActivator(activator) ?? this._toolSearchService;
},
getCommands,
setModel: async (model) => {
Expand Down Expand Up @@ -7491,6 +7506,7 @@ export class AgentSession {
previousActiveToolRegistrationIds?: ReadonlyMap<string, string>;
}): void {
this._delegatedCompactionKey = undefined;
this._toolSearchService = undefined;
const autoResizeImages = this.settingsManager.getImageAutoResize();
const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();
const shellPath = this.settingsManager.getShellPath();
Expand Down Expand Up @@ -7735,7 +7751,7 @@ export class AgentSession {

private _takeNativeToolSearchInjectionFailure(): string | null {
try {
return getToolSearchService().takeNativeInjectionFailure();
return (this._toolSearchService ?? getToolSearchService()).takeNativeInjectionFailure();
} catch {
return null;
}
Expand Down
Loading