Skip to content
Merged
58 changes: 41 additions & 17 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,14 @@ The Core Web is an administrator console for execution and resource operations;
business collaboration remains in Parsar. Environment Template management shares
the Session creation catalog and uses the existing public client operations. Patch
only edited fields, confirm deletion, and never automatically retry an uncertain
write. A Core connection change must discard the previous connection's forms,
pending results and notices. Saving a Template must not allocate a Runtime, call a
model or imply execution readiness. Keep unsupported advanced profiles explicit.
write. When Core refuses to delete a busy Session, offer an explicit Cancel work
and delete action that cancels once, reads until the Session is idle within a
bounded wait and deletes once; never cancel without that confirmation. When only
input waiting for its Environment blocks deletion, explain that it must start,
expire or fail instead, because Core rejects its cancellation. A Core connection
change must discard the previous connection's forms, pending results and notices.
Saving a Template must not allocate a Runtime, call a model or imply execution
readiness. Keep unsupported advanced profiles explicit.

For subsequent alignment and milestone closure batches, the main thread coordinates
design, shared interface agreements, file ownership, integration and merge. First
Expand Down Expand Up @@ -1144,7 +1149,7 @@ direct batches, including cancellation, while successful earlier retries remain
readable. Promotion commits the original inputs, history, reservation settlement
and execution claim (`queued` to `in_progress`) together; expiration and targeted
cancellation retain the terminal identity. Session deletion
cancels pending input in the same transaction. A terminal reservation retry must not
is rejected while input is pending and changes nothing. A terminal reservation retry must not
affect a later reservation or Turn. Evaluate deadlines after acquiring the Session
lock, and return terminal storage outcomes without rolling their transaction back.

Expand Down Expand Up @@ -1202,7 +1207,7 @@ An admitted retry returns the original receipts without reclaiming execution; a
read or uncertain commit never authorizes another Start. A crash after promotion
but before Start uses existing claimed-Turn reconciliation (`execution_interrupted`),
including unbound or deleted Sessions, rather than ordinary queued dispatch. Deletion
after claim requests cancellation under existing active-Turn semantics.
after claim is rejected like any active Turn.
The Worker expires at most 32 due reservations on each existing tick, after
checking ownership and before checking devices or execution slots. The sweep
requires the leased Store and uses its connection with the existing transaction
Expand Down Expand Up @@ -1643,21 +1648,40 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti
types. Product adapters live in `server/internal/agentdaemon`. Keep protocol
frames in `internal/agentdaemon/proto` until the contracts directory migration.
Store aliases preserve existing callers during this transition.
- Session deletion uses a durable `sessions.deleted_at` marker, committed with an
existing Turn cancellation request under the tenant Session lock. Public reads,
metadata changes, event streams and input admission exclude deleted Sessions;
admission checks visibility under that lock before retry lookup. Creation keys
remain reserved and cannot resurrect deleted Sessions. Missing/repeated deletion
locally returns 404 and reuse of a deleted creation identity returns 409; exact
hosted errors and overlapping stream timing remain unverified. Existing streams
close when removal is observed without a fabricated deletion event.
- Session deletion uses a durable `sessions.deleted_at` marker. Public deletion
accepts only a durably idle or failed Session without required actions: no
queued, in-progress or waiting root Turn and no pending input reservation, the
same settlement rule as the creation stream. Subagent child Turns and pending
Environment file writes are not checked, as before this rule; their official
behavior is unobserved. Take that decision and commit the marker
under the tenant Session lock that orders Turn and input admission, so either
admission commits first and deletion conflicts, or admission observes the
deletion. A busy Session returns 409 `conflict_error` with the observed official
message and nothing changes: no cancellation, marker, event or cleanup. Callers
cancel first (`agent.session.input.cancel`), wait until the Session is idle and
delete it. Core admits a Turn synchronously, so it also conflicts right after an
`events.create` 202, where the official service was observed to return 200.
Deleting a provisioning hosted Session with reserved input used to release its
sandbox node placement at once; it now conflicts, and the placement counts
toward node capacity until the input is admitted or its five-minute deadline
expires. A later allowed deletion releases an unallocated placement.
The owner's repeated deletion returns the same 200 confirmation without writing;
foreign, missing and malformed identifiers keep the byte-identical 404. Public
reads, metadata changes, event streams and input admission exclude deleted
Sessions; admission checks visibility under that lock before retry lookup.
Creation keys remain reserved and cannot resurrect deleted Sessions; reuse of a
deleted creation identity returns 409. Existing streams close when removal is
observed without a fabricated deletion event; overlapping stream timing remains
unverified. Earlier releases also deleted busy Sessions after requesting
cancellation, so upgraded databases can hold markers with hidden work.
Internal Turn/receipt/finalization and restart reconciliation retain access so
hidden work can settle under the existing execution lease. Queued deletion
prevents claim; an already claimed execution may complete or receive cancellation.
Confirmation does not guarantee native quiescence. Never revoke a shared device,
that work settles under the existing execution lease; queued work cannot be
claimed, and Runtime cleanup still cancels pending work. Confirmation does not
guarantee native quiescence. Never revoke a shared device,
remove a saved Agent or touch product data as part of Session deletion. Physical
SQL/native history cleanup remains a separate required implementation gap; these
records are retained, not claimed purged. Do not deploy a pre-deletion service
records are retained, not claimed purged, and purging may end repeat idempotency.
Do not deploy a pre-deletion service
against a database with deletion markers; migration rollback refuses to remove
the column while deleted records exist, preventing public resurrection.
- `services/agents-api` owns its SQL schema, sqlc queries and embedded goose
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,14 @@ import {
removeSession,
reconcileUnknownSessionDelete,
replaceSessionMetadata,
requestSessionCancelBeforeDelete,
requestSessionDelete,
requestSessionDetail,
requestSessionUpdate,
selectionAfterSessionDelete,
SessionActionError,
SessionMetadataConflictError,
waitForSessionIdle,
} from "./features/sessions/actions/session-actions";
import {
environmentObservationFromResource,
Expand Down Expand Up @@ -114,6 +116,7 @@ import {
} from "./lib/pending-function-result";
import {
beginPendingSend,
createIdempotencyKey,
failPendingSend,
type FailedPendingSend,
} from "./lib/pending-send";
Expand Down Expand Up @@ -2015,6 +2018,25 @@ export function App() {
return removeSessionFromWorkspace(sessionId, "Session deleted from Agent Core.");
};

// Runs only after the user confirms Cancel work and delete for a Session that
// Core refused to delete: cancel once, read until idle, then delete once.
const cancelAndDeleteSessionFromCore = async (sessionId: string): Promise<boolean> => {
const generation = coreGeneration;
const isCurrent = () => generation === connectionGenerationRef.current;
await requestSessionCancelBeforeDelete(core, sessionId, createIdempotencyKey());
const settled = await waitForSessionIdle(core, sessionId, { isCurrent });
if (settled === "stale" || !isCurrent()) {
throw new SessionActionError(
"The cancellation was sent, but the Core connection changed before deletion, so no deletion was attempted. The current Core view was kept.",
"request_failed",
);
}
if (settled === "missing") {
return removeSessionFromWorkspace(sessionId, "Session is absent from Agent Core after cancellation.");
}
return deleteSessionFromCore(sessionId);
};

const sendMessage = async (text: string) => {
const sessionId = selectedId;
if (!sessionId) return;
Expand Down Expand Up @@ -2358,6 +2380,7 @@ export function App() {
onAgentFilterChange={changeSessionAgentFilter}
onCreateSession={createSession}
onDeleteSession={deleteSessionFromCore}
onCancelAndDeleteSession={cancelAndDeleteSessionFromCore}
onFunctionResult={submitFunctionResult}
onListEnvironmentFiles={listEnvironmentFiles}
onCreateEnvironmentFile={createEnvironmentFile}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/features/CoreCollectionStates.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const sessionsCallbacks = {
onCancel: async () => undefined,
onCreateSession: async () => undefined,
onDeleteSession: async () => true,
onCancelAndDeleteSession: async () => true,
onFunctionResult: async () => undefined,
onRefresh: () => undefined,
onRetrySession: () => undefined,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/features/sessions/SessionsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ interface SessionsViewProps {
onCreateEnvironmentTemplate?: (input: CreateEnvironmentTemplateInput) => Promise<EnvironmentTemplate>;
onCreateSession: (input: SessionStartInput) => Promise<void>;
onDeleteSession: (sessionId: string) => Promise<boolean>;
onCancelAndDeleteSession: (sessionId: string) => Promise<boolean>;
onFunctionResult: (input: FunctionResultInput) => Promise<void>;
onListEnvironmentFiles?: ListEnvironmentFiles;
onCreateEnvironmentFile?: AgentCore["createEnvironmentFile"];
Expand Down Expand Up @@ -305,6 +306,7 @@ export function SessionsView({
onCreateEnvironmentTemplate,
onCreateSession,
onDeleteSession,
onCancelAndDeleteSession,
onFunctionResult,
onListEnvironmentFiles,
onCreateEnvironmentFile,
Expand Down Expand Up @@ -1000,6 +1002,7 @@ export function SessionsView({
session={actionSession}
onClose={() => setActionSession(null)}
onDelete={onDeleteSession}
onCancelAndDelete={onCancelAndDeleteSession}
onDeleted={(sessionId) => {
draftsBySessionRef.current.delete(sessionId);
if (selectedIdRef.current === sessionId) setMessage("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,14 @@ describe("Session actions dialog content", () => {
expect(html).toContain("server lifecycle semantics");
expect(html).toContain("not a promise of physical history erasure");
expect(html).toContain("Workspace files");
expect(html).not.toContain("Cancel work and delete");
});

it("explains cancel-then-delete only after Core reports a busy Session", () => {
const html = renderToStaticMarkup(<SessionDeleteConfirmation session={session} busy />);
expect(html).toContain("Cancel work and delete sends one cancellation");
expect(html).toContain("waits until Core reports the Session idle or failed");
expect(html).toContain("then sends one deletion");
expect(html).toContain("cannot be cancelled");
});
});
32 changes: 25 additions & 7 deletions apps/web/src/features/sessions/actions/SessionActionsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface SessionActionsDialogProps {
session: AgentSession | null;
onClose: () => void;
onDelete: (sessionId: string) => Promise<boolean>;
onCancelAndDelete: (sessionId: string) => Promise<boolean>;
onDeleted: (sessionId: string) => void;
onRetrieve: (sessionId: string) => Promise<AgentSession | undefined>;
onUpdate: (
Expand Down Expand Up @@ -69,13 +70,16 @@ export function SessionDetails({ session }: { session: AgentSession }) {
);
}

export function SessionDeleteConfirmation({ session }: { session: AgentSession }) {
export function SessionDeleteConfirmation({ session, busy = false }: { session: AgentSession; busy?: boolean }) {
return (
<div className="session-delete-confirmation">
<p>Delete <strong>{sessionTitle(session)}</strong> from Agent Core?</p>
<p className="session-delete-target">Exact Session: <code>{session.id}</code></p>
<p>The Web removes this Session only after Core confirms success. A missing, conflicting, unavailable, or uncertain response leaves the current durable view in place and is never retried automatically.</p>
<p>Parsar deletion follows server lifecycle semantics. It is not a promise of physical history erasure, immediate native executor shutdown, or deletion of executor Workspace files.</p>
{busy ? (
<p className="session-delete-busy">Cancel work and delete sends one cancellation for the current work, waits until Core reports the Session idle or failed, and then sends one deletion. Input still waiting for its Environment cannot be cancelled; wait for it to start or expire.</p>
) : null}
</div>
);
}
Expand Down Expand Up @@ -151,6 +155,7 @@ export function SessionActionsDialog({
session,
onClose,
onDelete,
onCancelAndDelete,
onDeleted,
onRetrieve,
onUpdate,
Expand All @@ -160,6 +165,8 @@ export function SessionActionsDialog({
const [detailLoading, setDetailLoading] = useState(false);
const [pending, setPending] = useState(false);
const [deleteRetryBlocked, setDeleteRetryBlocked] = useState(false);
// Core refused deletion because the Session still has work or pending input.
const [deleteBusy, setDeleteBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [formReplacement, setFormReplacement] = useState<{
revision: number;
Expand All @@ -180,6 +187,7 @@ export function SessionActionsDialog({
setActionError(null);
setFormReplacement(null);
setPending(false);
setDeleteBusy(false);
setDeleteRetryBlocked(uncertainDeleteSessionRef.current === session?.id);
setDetailLoading(Boolean(session));
if (!session) return;
Expand Down Expand Up @@ -227,6 +235,7 @@ export function SessionActionsDialog({
const returnToDetail = () => {
if (pending) return;
if (!deleteRetryBlocked) setActionError(null);
setDeleteBusy(false);
restoreDetailFocus.current = true;
setMode("detail");
};
Expand Down Expand Up @@ -264,14 +273,14 @@ export function SessionActionsDialog({
}
};

const confirmDelete = async () => {
const confirmDelete = async (cancelFirst = false) => {
if (!current || pending) return;
const request = requestRef.current + 1;
requestRef.current = request;
setActionError(null);
setPending(true);
try {
const confirmed = await onDelete(current.id);
const confirmed = await (cancelFirst ? onCancelAndDelete : onDelete)(current.id);
if (request !== requestRef.current) return;
if (!confirmed) {
throw new Error("The deletion confirmation belongs to an earlier Core connection. The current Core view and draft were kept.");
Expand All @@ -284,6 +293,9 @@ export function SessionActionsDialog({
uncertainDeleteSessionRef.current = current.id;
setDeleteRetryBlocked(true);
}
// Offer cancellation only for work it can stop; pending input cannot be cancelled.
if (error instanceof SessionActionError && error.kind === "session_busy") setDeleteBusy(true);
if (error instanceof SessionActionError && error.kind === "session_input_pending") setDeleteBusy(false);
setActionError(errorMessage(error));
}
} finally {
Expand All @@ -309,9 +321,15 @@ export function SessionActionsDialog({
) : mode === "delete" ? (
<>
<button ref={deleteCancelRef} className="button outline" type="button" onClick={returnToDetail} disabled={pending}>Cancel</button>
<button className="button danger" type="button" onClick={() => void confirmDelete()} disabled={unavailable || deleteRetryBlocked}>
{pending ? "Deleting…" : "Delete Session"}
</button>
{deleteBusy ? (
<button className="button danger" type="button" onClick={() => void confirmDelete(true)} disabled={unavailable || deleteRetryBlocked}>
{pending ? "Cancelling and deleting…" : "Cancel work and delete"}
</button>
) : (
<button className="button danger" type="button" onClick={() => void confirmDelete()} disabled={unavailable || deleteRetryBlocked}>
{pending ? "Deleting…" : "Delete Session"}
</button>
)}
</>
) : (
<>
Expand Down Expand Up @@ -340,7 +358,7 @@ export function SessionActionsDialog({
replacement={formReplacement}
/>
) : null}
{current && mode === "delete" ? <SessionDeleteConfirmation session={current} /> : null}
{current && mode === "delete" ? <SessionDeleteConfirmation session={current} busy={deleteBusy} /> : null}
</Modal>
</div>
);
Expand Down
Loading
Loading