Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions apps/cli/src/lib/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ control-plane path is DEPRECATED; do not add functionality to it.
frames and MUST sample without a cloud transport. Cloud observers/snapshots attach
only after the authorized remote bridge attaches and detach on offline/revocation.
Sampling is observer-lease driven; never start OS probes permanently or persist snapshots.
Recent resource history retains only bounded observed samples in memory. The local
`machine/get-resource-history` Machine RPC reads that buffer without probing; negotiate
`resourceHistory` v1. History is workspace-scoped and excludes command lines/environment.
Per-process attribution pins Windows creation-identity strings after first observation;
coarse POSIX `lstart` timestamps cannot establish identity, so those session process rows
are omitted. Aggregate resource estimates remain separate. History schemas reject unknown
nested resource fields and unsupported session statuses. Diagnostic attribution never
authorizes process termination.
- Machine Flock writes for this CLI's own machine must be local-first: after `repo.flush()`,
call `LoroDocumentManager.markMachineFlockDocDirty(...)` (or pass the manager as the
sync scheduler) instead of awaiting `handle.syncOnce()` in the user/RPC request path.
Expand Down Expand Up @@ -252,6 +260,12 @@ control-plane path is DEPRECATED; do not add functionality to it.
(`pressureRecheckAttempts`) because reclaim returns cache in milliseconds. Eviction is bounded
per call (`maxEvictionsPerCall`) because the caller awaits it on the prompt hot path. The
threshold is a safety MARGIN, never "what a turn needs" — do not phrase it that way to users.
GC eligibility captures runtime, history-mirror and metadata-version identity before awaited
reads, then checks again immediately before termination. Inspection errors protect only the
affected session. Cleanup holds dispatch, execution and manager admission leases through
document teardown and transient-store deletion.
Release manager and execution first, then dispatch so deferred RPC/meta work opens fresh state.
Direct start/continue/steer must reserve execution admission before accessing session documents.
- `provider-setup-manager.ts` owns durable default managed-builtin creation;
setup rows with executable runtime overrides are invalid. The future
config stays under `['providerSetup', configId]` while runtime/auth/live-probe
Expand Down Expand Up @@ -301,6 +315,14 @@ control-plane path is DEPRECATED; do not add functionality to it.
`tool_call` items in history — the CLI persists NO extra scheduled-task state (not in
`SessionMeta`, not a new history item); see `@lody/shared`
`collectPendingScheduledTasksFromHistory` + `nextCronFireMs`.
GC uses one history snapshot for active goals and background protection. Completed
scheduling tool calls protect the owning runtime until an explicit persisted cancellation;
an elapsed fire time is never proof of completion. Live pending/terminal work is checked
before and after asynchronous reads. Missing runtime ownership releases stale task-only
protection, while active goals remain persistent; a replacement runtime requires a fresh
eligibility check. If task completion/liveness is not observable for a live runtime,
preserve it conservatively instead of inventing a TTL. A failed history read protects only
its session and must not abort the sweep or become an unhandled interval rejection.
INVARIANT: `history-apply.ts` strips `rawInput`/`rawOutput` from ALL generic tool calls
(unstructured by spec) EXCEPT the four scheduling tools in `SCHEDULING_TOOL_NAMES`
(`CronCreate/CronDelete/CronList/ScheduleWakeup`, matched via `_meta.lody.toolName`),
Expand Down
54 changes: 45 additions & 9 deletions apps/cli/src/lib/loro/doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1241,7 +1241,7 @@ export class LoroDocumentManager {
// any one-shot renderer reconciliation without unloading the shared doc.
this.cancelLocalDocRoomBridge(docId);
const existing = this.sessions.get(sessionId);
if (existing) {
if (existing && !existing.isDestroyed) {
return existing;
}

Expand All @@ -1256,6 +1256,20 @@ export class LoroDocumentManager {
// open a fresh handle; otherwise the unload could evict the document that
// the newly activated SessionDocument is about to retain.
const initPromise = this.withLocalDocOwnership(docId, async () => {
const stale = this.sessions.get(sessionId);
if (stale) {
if (!stale.isDestroyed) return stale;
// Failed destruction may have already detached the room. Never expose
// that wrapper: retry teardown before opening a new repo handle.
await stale.destroy({ preserveStatus: true });
if (this.sessions.get(sessionId) === stale) this.sessions.delete(sessionId);
const replacement = this.sessions.get(sessionId);
if (replacement) {
if (replacement.isDestroyed)
throw new Error('Replacement session document requires cleanup');
return replacement;
}
}
const sessionDoc = new SessionDocument(
this.repo,
sessionId,
Expand Down Expand Up @@ -1671,23 +1685,31 @@ export class LoroDocumentManager {
sessionId: SessionId,
options: { preserveStatus?: boolean } = {}
): Promise<void> {
// Also await any in-flight init for this session
// Await initialization separately: only init failure means nothing to clean.
// A destroy failure must propagate and leave the exact wrapper owned for retry.
const pending = this.pendingSessionDocs.get(sessionId);
if (pending) {
const existing = this.sessions.get(sessionId);
let doc: SessionDocument | undefined;
try {
const doc = await pending;
await doc.destroy({ preserveStatus: options.preserveStatus });
this.sessions.delete(sessionId);
doc = await pending;
} catch {
// Init failed — nothing to clean up
// A recovery attempt can fail while its destroyed wrapper stays owned.
doc = existing;
}
this.pendingSessionDocs.delete(sessionId);
if (doc) {
await doc.destroy({ preserveStatus: options.preserveStatus });
if (this.sessions.get(sessionId) === doc) this.sessions.delete(sessionId);
}
if (this.pendingSessionDocs.get(sessionId) === pending)
this.pendingSessionDocs.delete(sessionId);
return;
}

const sessionDoc = this.sessions.get(sessionId);
if (sessionDoc) {
await sessionDoc.destroy({ preserveStatus: options.preserveStatus });
this.sessions.delete(sessionId);
if (this.sessions.get(sessionId) === sessionDoc) this.sessions.delete(sessionId);
}
}
}
Expand Down Expand Up @@ -1750,6 +1772,7 @@ export class SessionDocument implements LoroDocument<SessionDocMeta, SessionMeta
private readonly docRoomStatusListeners = new Set<(status: RepoTransportRoomStatus) => void>();
private historyAutoReadHandle: AutoMarkLatestUserHistoryAsReadHandle | null = null;
private destroyed = false;
private pendingDestroy: Promise<void> | undefined;

get isDestroyed(): boolean {
return this.destroyed;
Expand Down Expand Up @@ -3061,7 +3084,20 @@ export class SessionDocument implements LoroDocument<SessionDocMeta, SessionMeta
});
}

async destroy(options: { preserveStatus?: boolean } = {}) {
destroy(options: { preserveStatus?: boolean } = {}): Promise<void> {
if (this.pendingDestroy) return this.pendingDestroy;
const pending = this.destroyOnce(options);
this.pendingDestroy = pending;
const release = () => {
if (this.pendingDestroy === pending) this.pendingDestroy = undefined;
};
// A failed unload stays retryable, but concurrent callers must never start
// a second unload that could outlive the replacement document's creation.
void pending.then(release, release);
return pending;
}

private async destroyOnce(options: { preserveStatus?: boolean }) {
if (!this.mirror) {
return;
}
Expand Down
13 changes: 11 additions & 2 deletions apps/cli/src/lib/machine-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,14 @@ export class MachineRuntime {
async dispatchLocalMachineRpc(
message: LocalMachineRpcRequestValidated
): Promise<LocalMachineRpcResponse> {
if (message.method === 'machine/get-resource-history') {
if (message.ownerSessionId) {
return { ok: false, error: 'Machine resource history requires workspace-level access' };
}
return this.resourceMonitor
? { ok: true, result: this.resourceMonitor.getHistory() }
: { ok: false, error: 'Resource monitor stopped' };
}
const handler = this.requireHandler();
return await handler.handleLocalMachineRpc(message);
}
Expand All @@ -358,11 +366,12 @@ export class MachineRuntime {
this.gcManager = new SessionGCManager(gcConfig, {
getSessionLastActivity: (sessionId) => handler.getLastActivity(sessionId),
hasActiveTurn: (sessionId) => handler.hasActiveTurn(sessionId),
hasActiveGoal: async (sessionId) => await handler.hasActiveGoal(sessionId),
hasProtectedWork: async (sessionId) => await handler.hasProtectedWork(sessionId),
captureCleanupGuard: (sessionId) => handler.captureGCCleanupGuard(sessionId),
hasPendingUpdates: (sessionId) => handler.hasPendingUpdates(sessionId),
hasPendingUserWork: async (sessionId) => await handler.hasPendingUserWork(sessionId),
isArchiveInFlight: (sessionId) => handler.isArchiveInFlight(sessionId),
cleanSession: (sessionId) => handler.cleanSessionForGC(sessionId),
cleanSession: (sessionId, isCurrent) => handler.cleanSessionForGC(sessionId, isCurrent),
getSessionIds: () => handler.getTrackedSessionIds(),
memoryPressure: this.options.memoryPressure,
logger: this.options.logger,
Expand Down
111 changes: 86 additions & 25 deletions apps/cli/src/lib/message-handler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { hasBackgroundWorkFromHistory } from './session-background-work';
import os from 'os';
import fs from 'fs';
import path from 'path';
Expand Down Expand Up @@ -6551,6 +6552,8 @@ export class MessageHandler {
};

switch (request.method) {
case 'machine/get-resource-history':
throw new Error('Resource history is served by the machine runtime');
case 'code-collab/get-file-index':
await assertOwner(request.params.sessionId as SessionId);
return await this.codeCollabV2Service.getFileIndex(request.params);
Expand Down Expand Up @@ -9931,17 +9934,29 @@ export class MessageHandler {
return hasPendingUserTurnActivation(meta);
}

/**
* Persistent active goals may drive a later autonomous ACP cycle even while
* no prompt is running. They are not a live-presence signal, but evicting the
* ACP process would discard that resumable session state.
*/
async hasActiveGoal(sessionId: SessionId): Promise<boolean> {
/** One history read covers goals and tasks; runtime ownership bounds task protection. */
async hasProtectedWork(sessionId: SessionId): Promise<boolean> {
const runtime = this.sessionManager.getSession(sessionId);
if (runtime?.terminalManager.hasRunningTerminals?.()) {
return true;
}
const sessionDoc = await this.workspaceDocument.getOrCreateSessionDoc(sessionId);
const history = await sessionDoc.getHistory();
Comment thread
slashdevcorpse marked this conversation as resolved.
const historyGoal = resolveLatestSessionGoalFromHistory(history);
const meta = await sessionDoc.getMetaState();
const legacyMeta = meta as SessionLegacyMetaFields | null | undefined;
const historyGoal = resolveLatestSessionGoalFromHistory(await sessionDoc.getHistory());
return isSessionGoalActive(historyGoal ?? legacyMeta?.latestGoal);
// Goals intentionally persist beyond runtime exit. Task snapshots do not:
// once the owning runtime is gone they cannot pin its transient state.
if (isSessionGoalActive(historyGoal ?? legacyMeta?.latestGoal)) return true;
const currentRuntime = this.sessionManager.getSession(sessionId);
if (!currentRuntime) return false;
// A replacement arrived while reading. This snapshot cannot establish that
// replacement's idleness; defer its cleanup until a fresh eligibility check.
if (currentRuntime !== runtime) return true;
return (
currentRuntime.terminalManager.hasRunningTerminals?.() === true ||
hasBackgroundWorkFromHistory(history)
);
}

/**
Expand Down Expand Up @@ -10025,27 +10040,73 @@ export class MessageHandler {
* Clean all transient state for a session.
* Called by GC manager when a session has been idle or evicted under memory pressure.
*/
async cleanSessionForGC(sessionId: SessionId): Promise<void> {
captureGCCleanupGuard(sessionId: SessionId): () => boolean {
const runtime = this.sessionManager.getSession(sessionId);
const sessionDoc = this.workspaceDocument.sessions.get(sessionId);
const mirror = sessionDoc?.mirror;
// Mirror.getState returns its immutable current state, not getHistory's
// normalized copy. Identity changes invalidate this eligibility read without
// another history scan or a retained per-session cache.
const state = mirror?.getState();
const metadata = this.workspaceDocument.repo.getMeta();
// Metadata is a separate Flock room. Its version is a fresh plain object,
// so compare sorted clock values, not the object identity or history mirror.
const metadataVersion = () =>
JSON.stringify(
Object.entries(metadata.version())
.sort(([a], [b]) => a.localeCompare(b))
.map(([peer, clock]) => [peer, clock?.physicalTime, clock?.logicalCounter])
);
const version = metadataVersion();
return () =>
this.sessionManager.getSession(sessionId) === runtime &&
this.workspaceDocument.sessions.get(sessionId) === sessionDoc &&
sessionDoc?.mirror === mirror &&
mirror?.getState() === state &&
this.workspaceDocument.repo.getMeta() === metadata &&
metadataVersion() === version &&
!this.sessionDispatchWatcher.hasPendingDispatch(sessionId) &&
!this.hasActiveTurn(sessionId) &&
!this.hasPendingUpdates(sessionId) &&
!this.isArchiveInFlight(sessionId) &&
runtime?.terminalManager.hasRunningTerminals?.() !== true;
}

async cleanSessionForGC(sessionId: SessionId, isCurrent: () => boolean): Promise<boolean> {
if (!isCurrent()) return false;
this.logger.debug(`[GC] Cleaning session ${sessionId}`);

// 1. Clear active presence
this.clearSessionActivePresence(sessionId);

await this.previewService.closeSessionPreviewForCleanup(sessionId, 'Session cleaned by GC');

// 2. Terminate session process first — if later steps throw, the process
// is already gone and the session stays tracked for retry/cleanup.
if (this.sessionManager.hasSession(sessionId)) {
await this.sessionManager.terminateSession(sessionId, true);
// The preview close may yield to a new turn, terminal, goal or replacement.
// No await may separate this guard from terminateSession: it synchronously
// invokes Session.terminate, which closes admission before its first await.
if (!isCurrent()) return false;
const releaseDispatch = this.sessionDispatchWatcher.tryAcquireGCCleanupLease(sessionId);
if (!releaseDispatch) return false;
let releaseExecution: (() => void) | null = null;
let releaseManager: (() => void) | null = null;
try {
releaseExecution = this.executionService.tryAcquireGCCleanupLease(sessionId);
if (!releaseExecution) return false;
releaseManager = this.sessionManager.tryAcquireGCCleanupLease(sessionId);
if (!releaseManager || !isCurrent()) return false;
const termination = this.sessionManager.terminateSession(sessionId, true);
this.clearSessionActivePresence(sessionId);
await termination;

// Dispatch, direct turn writes and replacement creation stay excluded
// across document destruction's awaits. Newly arriving RPC/meta work is
// retained by the watcher and resumes against a fresh doc after release.
await this.workspaceDocument.cleanSessionDoc(sessionId);
this.store.deleteSession(sessionId);

this.logger.debug(`[GC] Session ${sessionId} cleaned`);
return true;
} finally {
releaseManager?.();
releaseExecution?.();
releaseDispatch();
}

// 3. Clean Loro documents (main memory savings)
await this.workspaceDocument.cleanSessionDoc(sessionId);

// 4. Drop transient tracking last — only after all cleanup succeeded,
// so getTrackedSessionIds() can still see it for retry if steps above throw.
this.store.deleteSession(sessionId);

this.logger.debug(`[GC] Session ${sessionId} cleaned`);
}
}
Loading
Loading