Skip to content
Merged
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
41 changes: 40 additions & 1 deletion apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ import {
createSessionService,
STALE_RUNNING_SESSION_FRESH_ACTIVITY_GRACE_MS,
} from "../../desktop/src/main/services/sessions/sessionService";
import { createSettleTeardownWiring } from "../../desktop/src/main/services/sessions/settleTeardownWiring";
import type {
SettleResidueItem,
SettleTeardownContext,
SettleTeardownOutcome,
} from "../../desktop/src/main/services/sessions/sessionSettleTeardown";
import { createProjectConfigService } from "../../desktop/src/main/services/config/projectConfigService";
import { createConflictService } from "../../desktop/src/main/services/conflicts/conflictService";
import { createGitOperationsService } from "../../desktop/src/main/services/git/gitOperationsService";
Expand Down Expand Up @@ -755,7 +761,30 @@ export async function createAdeRuntime(args: {
// services. Session changes still use it once publishing is attached.
let pushPublisherForPtySignals: PushPublisherService | null = null;
let ptyServiceForSessionChanges: ReturnType<typeof createPtyService> | null = null;
const sessionService = createSessionService({ db });
// Late-bound: the chat service that owns the work is constructed further
// down. Without this the brain — which owns phone sync, remote commands and
// the PR-merge poller in a normal install — would settle sessions while
// stopping nothing.
const settleTeardownRef: {
run: ((sessionId: string, ctx: SettleTeardownContext) => Promise<SettleTeardownOutcome>) | null;
report: ((args: { columns: string[]; changesetSessionCount: number }) => void) | null;
residue: ((args: { provider: string | null; items: SettleResidueItem[] }) => void) | null;
} = { run: null, report: null, residue: null };
const sessionService = createSessionService({
db,
runSettleTeardown: async (sessionId, ctx) =>
settleTeardownRef.run ? await settleTeardownRef.run(sessionId, ctx) : { residue: [], confirmed: false },
onRemoteSettleWrite: (args) => settleTeardownRef.report?.(args),
onSettleResidue: (args) => settleTeardownRef.residue?.(args),
});
// Inbound settle-tuple writes get this host's lifecycle revision, so an
// in-flight settle can see a peer's decision and abandon rather than
// overwrite it. Registered here because the DB layer must not know what a
// settle means — and because the brain, not the desktop, is where changesets
// are actually applied in a normal install.
db.sync.setRemoteSettleTupleHandler((changes) => {
sessionService.reconcileRemoteSettleTuple(changes);
});
sessionService.onChanged((event) => {
pushEvent("runtime", { type: "terminal_session_changed", event });
const session = sessionService.get(event.sessionId);
Expand Down Expand Up @@ -1249,6 +1278,16 @@ export async function createAdeRuntime(args: {
countActiveForLane: (laneId) => agentChatService.countActiveForLane(laneId),
disposeForLane: (laneId) => agentChatService.disposeForLane(laneId),
};
const settleWiring = createSettleTeardownWiring({
agentChatService,
logger,
analytics: productAnalyticsService ?? null,
// The brain is the non-GUI runtime surface, matching its other analytics.
surface: "api",
});
settleTeardownRef.run = settleWiring.runSettleTeardown;
settleTeardownRef.report = settleWiring.onRemoteSettleWrite;
settleTeardownRef.residue = settleWiring.onSettleResidue;
}
autoRebaseActivityReady = true;
void autoRebaseService
Expand Down
39 changes: 38 additions & 1 deletion apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ import { releaseLaneRuntimeResources } from "./services/lanes/laneRuntimeLifecyc
import { createOAuthRedirectService } from "./services/lanes/oauthRedirectService";
import { createRuntimeDiagnosticsService } from "./services/lanes/runtimeDiagnosticsService";
import { createSessionService } from "./services/sessions/sessionService";
import type { SettleResidueItem, SettleTeardownContext, SettleTeardownOutcome } from "./services/sessions/sessionSettleTeardown";
import { createSettleTeardownWiring } from "./services/sessions/settleTeardownWiring";
import { createSessionDeltaService } from "./services/sessions/sessionDeltaService";
import { createPtyService } from "./services/pty/ptyService";
import { createSupervisedPtyLoader } from "./services/pty/supervisedPtyHost";
Expand Down Expand Up @@ -2870,10 +2872,34 @@ app.whenReady().then(async () => {
emitProjectEvent(projectRoot, IPC.lanesEnvEvent, ev),
});

const sessionService = createSessionService({ db });
// Late-bound: the chat service that owns the work does not exist yet at
// this point, and the settle path must not depend on construction order.
const settleTeardownRef: {
run: ((sessionId: string, ctx: SettleTeardownContext) => Promise<SettleTeardownOutcome>) | null;
report: ((args: { columns: string[]; changesetSessionCount: number }) => void) | null;
residue: ((args: { provider: string | null; items: SettleResidueItem[] }) => void) | null;
} = { run: null, report: null, residue: null };
const sessionService = createSessionService({
db,
onRemoteSettleWrite: (args) => settleTeardownRef.report?.(args),
onSettleResidue: (args) => settleTeardownRef.residue?.(args),
runSettleTeardown: async (sessionId, ctx) =>
settleTeardownRef.run
? await settleTeardownRef.run(sessionId, ctx)
// Before the chat service is up there is no background work to stop,
// so an empty teardown is the honest answer, not a skipped one.
: { residue: [], confirmed: false },
});
sessionService.onChanged((event) => {
emitProjectEvent(projectRoot, IPC.sessionsChanged, event);
});
// Inbound settle-tuple writes go through the chokepoint instead of landing
// raw, so a peer's decision gains this host's revision, settling window and
// abort semantics (R7). Registered here because the DB layer must not know
// what a settle means.
db.sync.setRemoteSettleTupleHandler((changes) => {
sessionService.reconcileRemoteSettleTuple(changes);
});
const processRegistry = createProcessRegistryService({
db,
logger,
Expand Down Expand Up @@ -3600,6 +3626,17 @@ app.whenReady().then(async () => {
countActiveForLane: (laneId) => agentChatService.countActiveForLane(laneId),
disposeForLane: (laneId) => agentChatService.disposeForLane(laneId),
};
{
const wiring = createSettleTeardownWiring({
agentChatService,
logger,
analytics: productAnalyticsService ?? null,
surface: "desktop",
});
settleTeardownRef.run = wiring.runSettleTeardown;
settleTeardownRef.report = wiring.onRemoteSettleWrite;
settleTeardownRef.residue = wiring.onSettleResidue;
}
autoRebaseActivityReady = true;
void autoRebaseService
.refreshActiveRebaseNeeds("activity_services_ready")
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/services/adeActions/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ describe("isAllowedAdeAction", () => {
expect(isAllowedAdeAction("session", "requestSessionAttention")).toBe(true);
expect(isAllowedAdeAction("session", "setSessionStatusNote")).toBe(true);
expect(isAllowedAdeAction("session", "settleSession")).toBe(true);
// The residue read path. It was added to the CTO-only list but NOT to the
// allowlist, which silently refused every call — and left the settle design
// claiming a user-visible guarantee ("settled never quietly means something
// is still running") that nothing could actually reach.
expect(isAllowedAdeAction("session", "getSettleResidue")).toBe(true);
expect(isAllowedAdeAction("session", "unsettleSession")).toBe(true);
expect(isCtoOnlyAdeAction("session", "settleSession")).toBe(true);
expect(isCtoOnlyAdeAction("session", "unsettleSession")).toBe(true);
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,7 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri
"get",
"getDelta",
"getLifecycleSettings",
"getSettleResidue",
"list",
"readTranscriptTail",
"requestSessionAttention",
Expand Down Expand Up @@ -2193,6 +2194,20 @@ function buildSessionDomainService(runtime: AdeRuntime): OpaqueService | null {
sessionService.unsettleSessions(sessionIds);
return { ok: true };
},
/**
* Work a settle could not confirm it stopped (design 3d option 3).
*
* Read-only, and the reason it exists: option 3 was signed off on the
* condition that the residue stay DISCOVERABLE rather than merely recorded.
* Without a read path, "settled" would quietly mean "and something may still
* be running" — the exact outcome the option was chosen to avoid.
*/
getSettleResidue: (args?: unknown) => {
const record = readObjectActionArg(args, "session.getSettleResidue");
const sessionId = typeof record.sessionId === "string" ? record.sessionId : "";
if (!sessionId) throw new Error("session.getSettleResidue requires sessionId.");
return sessionService.getSettleResidue(sessionId) ?? { recordedAt: null, items: [] };
},
// -----------------------------------------------------------------------
// Snooze / wake / settle-override. Snooze is a synced VISIBILITY overlay:
// it hides a row until its deadline without touching lifecycle columns, so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record<string
}),
execute: async ({ sessionId, outcome }) => {
try {
const result = deps.sessionService.settleSessionReportingAbort(sessionId, {
const result = await deps.sessionService.settleSessionReportingAbort(sessionId, {
...(outcome ? { outcome } : {}),
source: "operator",
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ const STRING_PROPERTIES = new Set([
"duration_bucket", "error_kind", "route_kind", "connection_state", "drop_reason", "source", "mode",
"entry_point", "release_channel", "summary_kind", "reason", "last_command", "leg", "code",
"escalation_reason", "install_source", "trigger", "from_version", "to_version", "user_action",
"tool_error_kind", "crash_reason",
"tool_error_kind", "crash_reason", "count_bucket",
]);
const NUMBER_PROPERTIES = new Set([
"sent_count", "dropped_count", "interaction_count", "session_count", "chat_session_count",
Expand All @@ -120,6 +120,10 @@ const ANALYTICS_ONLY_ACTIONS = new Set([
"mention_expanded",
"transaction_failed",
"scope_selected",
// Settle teardown: work a settle could not confirm it stopped, and a peer
// settle-tuple write that had to be reconciled through the chokepoint.
"settle_teardown_residue",
"settle_remote_write_reconciled",
]);

const EVENT_PROPERTY_KEYS: Record<ProductAnalyticsEventName, ReadonlySet<string>> = {
Expand All @@ -132,7 +136,7 @@ const EVENT_PROPERTY_KEYS: Record<ProductAnalyticsEventName, ReadonlySet<string>
ade_project_opened: new Set(["route_kind", "source", "mode", "connection_state"]),
ade_feature_used: new Set([
"feature", "action", "outcome", "source", "mode", "provider", "model_family", "duration_bucket", "connection_state",
"bytes_freed", "files_compressed",
"bytes_freed", "files_compressed", "count_bucket",
]),
ade_work_session_started: new Set(["feature", "action", "outcome", "source", "mode", "provider"]),
ade_work_session_completed: new Set([
Expand Down Expand Up @@ -183,6 +187,9 @@ const SAFE_STRING_VALUES: Partial<Record<string, ReadonlySet<string>>> = {
outcome: new Set([
"success", "started", "completed", "failure", "timeout", "opened", "cancelled", "approved", "denied",
"partial", "failed", "idle_only", "immediate",
// Settle teardown could not confirm a stop (design 3d). `timeout` above
// covers the third case. Coarse on purpose: never the task or its error.
"no_stop_control", "rejected",
// Which half of a post-update transaction did not land. `swap` is
// deliberately absent: the app half is already reported by
// `ade_update_install_did_not_land`, so only the brain half is new signal.
Expand All @@ -194,12 +201,15 @@ const SAFE_STRING_VALUES: Partial<Record<string, ReadonlySet<string>>> = {
// widened, so the scope control can never carry free text.
"machine", "project", "account",
]),
provider: new Set(["codex", "openai", "claude", "cursor", "droid", "opencode", "pi", "gemini", "local", "other"]),
provider: new Set(["codex", "openai", "claude", "cursor", "droid", "opencode", "pi", "gemini", "lmstudio", "local", "other"]),
model_family: new Set([
"gpt_5", "openai_reasoning", "claude_sonnet", "claude_opus", "claude_haiku", "cursor", "gemini",
"grok", "local", "other",
]),
duration_bucket: new Set(["under_10s", "under_1m", "under_5m", "under_30m", "under_2h", "over_2h"]),
// Bucketed, never a raw count: a fleet that fails to stop must not become a
// high-cardinality dimension.
count_bucket: new Set(["1", "2_5", "6_plus"]),
route_kind: new Set(["desktop", "web"]),
connection_state: new Set(["connected", "disconnected", "pairing", "direct", "relay", "error"]),
drop_reason: new Set([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,42 @@ describe("product analytics producers", () => {
})).toMatchObject({ provider: "pi" });
});

it("keeps the settle-teardown properties through the sanitizer", () => {
// Both of these were silently dropped when first added: `action` is
// allowlisted separately from the event's key list, and `count_bucket` was
// registered in the key list and the value allowlist but never in the
// string-dispatch set, so it never reached either. The event still shipped,
// just anonymous — which is worse than not shipping, because the dashboard
// looks populated.
expect(sanitizeProductAnalyticsProperties("ade_feature_used", {
feature: "work",
action: "settle_teardown_residue",
outcome: "no_stop_control",
provider: "codex",
count_bucket: "2_5",
})).toEqual({
feature: "work",
action: "settle_teardown_residue",
outcome: "no_stop_control",
provider: "codex",
count_bucket: "2_5",
});

expect(sanitizeProductAnalyticsProperties("ade_feature_used", {
feature: "work",
action: "settle_remote_write_reconciled",
outcome: "partial",
})).toMatchObject({ action: "settle_remote_write_reconciled" });

// The bucket is still a closed set: a raw count must not slip through and
// widen the dimension.
expect(sanitizeProductAnalyticsProperties("ade_feature_used", {
feature: "work",
action: "settle_teardown_residue",
count_bucket: "37",
})).not.toHaveProperty("count_bucket");
});

it("maps automation completion and failed chat turns into canonical bounded outcomes", () => {
const captures: ProductAnalyticsCapture[] = [];
const analytics = settledAnalytics(captures);
Expand Down
54 changes: 47 additions & 7 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37495,7 +37495,13 @@ export function createAgentChatService(args: {
} catch {
// Ignore provider abort failures; SSE cancellation still tears the turn down.
}
cancelQueuedSteers(managed, managed.runtime, "interrupted");
// `stop_only` exists so settle teardown can stop a turn WITHOUT
// discarding the user's queued follow-ups. Only the Claude path honoured
// it, so a settle on these providers silently deleted queued prompts —
// unrecoverable, and the opposite of the rule that losing a settle costs
// one click while losing the user's work does not. Default is
// `stop_and_clear`, so the Stop button is unaffected.
if (mode === "stop_and_clear") cancelQueuedSteers(managed, managed.runtime, "interrupted");
persistChatState(managed);
for (const pending of managed.runtime.pendingApprovals.values()) {
managed.runtime.handle.client.postSessionIdPermissionsPermissionId({
Expand Down Expand Up @@ -37541,7 +37547,7 @@ export function createAgentChatService(args: {
cancelCursorPermissionWaiter(w, "Cursor tool approval was cancelled because the turn was interrupted.");
}
rt.permissionWaiters.clear();
cancelQueuedSteers(managed, rt, "interrupted");
if (mode === "stop_and_clear") cancelQueuedSteers(managed, rt, "interrupted");
return result;
}

Expand All @@ -37554,15 +37560,17 @@ export function createAgentChatService(args: {
} catch {
// ignore
}
cancelQueuedSteers(managed, rt, "interrupted");
if (mode === "stop_and_clear") cancelQueuedSteers(managed, rt, "interrupted");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve Pi follow-ups during stop-only interrupts

When a busy Pi session has queued follow-ups, settle teardown invokes this branch with mode: "stop_only", but rt.pendingSteers.length = 0 has already deleted those prompts before this new guard runs. Fresh evidence beyond the earlier comment is that the partial fix guards only cancelQueuedSteers; the preceding direct queue clear remains unconditional, so Pi settles still irreversibly discard user input.

Useful? React with 👍 / 👎.

cancelPendingPiInputs(managed);
persistChatState(managed);
return result;
}

if (managed.session.provider === "pi") {
piRuntimeSetupInterruptRequested.set(managed, true);
cancelQueuedSteers(managed, { pendingSteers: [], activeTurnId: null }, "interrupted");
if (mode === "stop_and_clear") {
cancelQueuedSteers(managed, { pendingSteers: [], activeTurnId: null }, "interrupted");
}
setSessionIdle(managed);
persistChatState(managed);
return result;
Expand All @@ -37580,20 +37588,24 @@ export function createAgentChatService(args: {
cancelDroidPermissionWaiter(w, "Droid tool approval was cancelled because the turn was interrupted.");
}
rt.permissionWaiters.clear();
cancelQueuedSteers(managed, rt, "interrupted");
if (mode === "stop_and_clear") cancelQueuedSteers(managed, rt, "interrupted");
return result;
}

if (managed.session.provider === "droid") {
droidRuntimeSetupInterruptRequested.set(managed, true);
cancelQueuedSteers(managed, { pendingSteers: [], activeTurnId: null }, "interrupted");
if (mode === "stop_and_clear") {
cancelQueuedSteers(managed, { pendingSteers: [], activeTurnId: null }, "interrupted");
}
persistChatState(managed);
return result;
}

if (managed.session.provider === "cursor") {
cursorRuntimeSetupInterruptRequested.set(managed, true);
cancelQueuedSteers(managed, { pendingSteers: [], activeTurnId: null }, "interrupted");
if (mode === "stop_and_clear") {
cancelQueuedSteers(managed, { pendingSteers: [], activeTurnId: null }, "interrupted");
}
persistChatState(managed);
return result;
}
Expand Down Expand Up @@ -44283,6 +44295,34 @@ export function createAgentChatService(args: {
dispatchSteer,
cancelDispatchedSteer,
interrupt,
/**
* Is a persisted Claude `--bg` job actually still running?
*
* `claudeBackgroundJobShort` is a RECORD, not a liveness signal — it stays
* on the session after the job finishes or is stopped. Settle teardown has
* to distinguish the two: counting a finished job as work makes every later
* settle spend the confirmation budget and then report residue that does
* not exist.
*/
hasLiveClaudeBackgroundJob: async (
short: string | null | undefined,
): Promise<"alive" | "gone" | "unknown"> => {
const normalized = normalizeClaudeBackgroundShort(short);
if (!normalized) return "gone";
const socketPath = await resolveClaudeDaemonControlSocket();
// No daemon socket, or a request that failed: we do not KNOW the job is
// gone. Collapsing that to "gone" is how a settle confirms a clean
// teardown over a job that is still running — the same mistake as
// treating a timed-out liveness read as an idle session.
if (!socketPath) return "unknown";
try {
const response = await sendClaudeDaemonRequest(socketPath, { op: "has", short: normalized });
if (response.ok !== true) return "unknown";
return response.alive === true || response.present === true ? "alive" : "gone";
} catch {
return "unknown";
}
},
restoreCancelledQueue,
recoverTurn,
recoverCodexTurn,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ function createInMemoryAdeDb(): { db: AdeDb; raw: Database } {
rebuiltFts: false,
}),
discardUnpublishedChangesForTables: () => {},
setRemoteSettleTupleHandler: () => {},
},
flushNow: () => undefined,
close: () => raw.close(),
Expand Down
Loading
Loading