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
23 changes: 11 additions & 12 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1547,11 +1547,6 @@ export async function createAdeRuntime(args: {
const service = createSyncTunnelClientService({
logger,
configStore: cloudRelayStore,
getSyncPort: () => resolvedArgs.syncRuntime?.sharedSyncListener?.getPort() ?? null,
getExpectedLoopbackNonce: () =>
resolvedArgs.syncRuntime?.sharedSyncListener?.getExpectedLoopbackNonce() ?? null,
getRelayBridgeProof: () =>
resolvedArgs.syncRuntime?.sharedSyncListener?.getRelayBridgeProof() ?? null,
isAccountSignedIn: () => {
const status = accountAuthService.getStatus();
return status.signedIn && Boolean(status.userId?.trim());
Expand All @@ -1568,15 +1563,19 @@ export async function createAdeRuntime(args: {
},
onPublicationStateChanged: () => resolvedArgs.syncRuntime?.requestAccountMachinePublish?.(),
});
resolvedArgs.syncRuntime?.sharedSyncListener?.onLoopbackValidated(() => {
void service.validateCurrentBridge().catch((error) => {
logger.warn("sync.tunnel_bridge_validation_failed", {
error: error instanceof Error ? error.message : String(error),
});
});
});
return service;
});
// Bind the listener OUTSIDE the factory. The client is cached one-per-machine
// and built by whichever runtime bootstrapped first, which is regularly a
// scope with no listener (headless one-shot, embedded fallback). Everything
// captured in that factory — the port accessor and the loopback retry hook —
// then pointed at null for the life of the process, so the bridge could never
// validate and Relay stayed fail-closed even though the listener was up.
// Attaching here means the runtime that actually owns the listener wins,
// whether or not it was the one that created the instance.
if (resolvedArgs.syncRuntime?.sharedSyncListener) {
syncTunnelClientService.attachHostListener(resolvedArgs.syncRuntime.sharedSyncListener);
}
// Only the runtime that actually hosts phone sync (owns the brain-level
// shared listener) may register the relay tunnel. The relay DO keeps ONE
// host socket per machineKey (last wins), so a headless one-shot CLI
Expand Down
62 changes: 62 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1328,6 +1328,68 @@ describe("ADE CLI", () => {
expect(output).not.toContain("no PIN set");
});

it("surfaces a prior install that did not land in update status --text", () => {
const plan = expectExecutePlan(buildCliPlan(["update", "status"]));
expect(inferFormatter(plan)).toBe("update-status");

const output = formatOutput(
{
status: "ready",
currentVersion: "1.2.37",
latestKnownVersion: "1.2.38",
version: "1.2.38",
progressPercent: null,
bytesPerSecond: null,
transferredBytes: null,
totalBytes: null,
releaseNotesUrl: null,
error: null,
errorDetails: null,
recentlyInstalled: null,
parked: null,
lastInstallFailed: { targetVersion: "1.2.38", attempt: 2 },
autoApplyPending: null,
autoApplySuppressedUntil: null,
},
{ text: true } as any,
inferFormatter(plan),
);

expect(output).toContain("status");
expect(output).toContain("1.2.38 did not land");
expect(output).toContain("attempt 2");
expect(output).toContain("ade update install");
});

it("renders progress and omits the install-failure row for a clean update snapshot", () => {
const plan = expectExecutePlan(buildCliPlan(["update", "status"]));
const output = formatOutput(
{
status: "downloading",
currentVersion: "1.2.37",
latestKnownVersion: "1.2.38",
version: "1.2.38",
progressPercent: 42.4,
bytesPerSecond: 1_048_576,
transferredBytes: 1_048_576,
totalBytes: 4_194_304,
releaseNotesUrl: null,
error: null,
errorDetails: null,
recentlyInstalled: null,
parked: null,
lastInstallFailed: null,
autoApplyPending: null,
autoApplySuppressedUntil: null,
},
{ text: true } as any,
inferFormatter(plan),
);

expect(output).toContain("42% · 1 MB of 4 MB · 1 MB/s");
expect(output).not.toContain("last install failed");
});

it("applies sync web clipboard and open flags only when a link exists", () => {
const options = {
...baseResolveOpts(),
Expand Down
110 changes: 105 additions & 5 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,8 @@ type FormatterId =
| "storage-compress"
| "storage-maintenance"
| "sync-status"
| "sync-web";
| "sync-web"
| "update-status";

type ChatWaitTarget =
| "idle"
Expand Down Expand Up @@ -2304,16 +2305,27 @@ ${CURSOR_CLOUD_HELP.cloud}`,
the post-install notice. quitAndInstall relaunches the desktop app and only
succeeds when status is "ready".

$ ade --socket update status --text Read AutoUpdateSnapshot (status, version, progress)
$ ade --socket update status --text Read AutoUpdateSnapshot (status, version, progress, last failed install)
$ ade --socket update check --text Trigger a background update check
$ ade --socket update install --text Refresh latest, then quit and install when ready
$ ade --socket update dismiss --text Clear the recently-installed banner
$ ade --socket update actions --text List callable update actions

Snapshot status values: idle, checking, downloading, ready, installing, error.
"installing" appears between quitAndInstall and the desktop relaunch; if the
install fails, status falls back to error and the pending-install record is
cleared automatically.
"installing" appears between quitAndInstall and the desktop relaunch. That
window is deliberately long — the OS installer stages the new bundle in
process on macOS, so the app is only force-quit after a hard bound of several
minutes (about a minute on Windows/Linux, where staging is external). Do NOT
read a few slow minutes in "installing" as a hang, and do not kill the desktop
app to "unstick" it: that is exactly what makes an install fail to land.

If quitAndInstall fails before the native handoff, status falls back to error
and the pending-install record is cleared. If the app quits but relaunches on
the OLD version, the install did not land: the next snapshot carries
"lastInstallFailed": { targetVersion, attempt }, which survives the restart.
Check that field before re-offering the same update — the first failure keeps
the downloaded archive so a retry is just another install, and only a second
failure discards the download and forces a fresh one.
`,
};

Expand Down Expand Up @@ -16834,6 +16846,91 @@ function formatStorageMaintenance(value: unknown): string {
return `${header}\n\n${table}`;
}

function formatEpochTimestamp(value: unknown): string | null {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
// Finite and positive still admits epochs past 8.64e15, where toISOString
// throws RangeError. Every other field here degrades to a missing row rather
// than taking down `ade update status --text`; this one must too.
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
const iso = date.toISOString();
return `${iso} (${relativeTime(iso)})`;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function formatUpdateStatus(value: unknown): string {
if (!isRecord(value)) return JSON.stringify(value, null, 2);
const errorDetails = isRecord(value.errorDetails) ? value.errorDetails : null;
const recentlyInstalled = isRecord(value.recentlyInstalled) ? value.recentlyInstalled : null;
const parked = isRecord(value.parked) ? value.parked : null;
// A previous install quit and the app came back on the old version. The
// snapshot deliberately keeps this across the restart, so an agent reading
// status must see it — otherwise the same update is silently offered again
// and the failure reads as "the update did nothing".
const lastInstallFailed = isRecord(value.lastInstallFailed) ? value.lastInstallFailed : null;
const autoApplyPending = isRecord(value.autoApplyPending) ? value.autoApplyPending : null;

const progressParts: string[] = [];
if (typeof value.progressPercent === "number" && Number.isFinite(value.progressPercent)) {
progressParts.push(`${Math.round(value.progressPercent)}%`);
}
if (typeof value.transferredBytes === "number" && typeof value.totalBytes === "number") {
progressParts.push(
`${formatBytes(value.transferredBytes)} of ${formatBytes(value.totalBytes)}`,
);
}
if (typeof value.bytesPerSecond === "number" && value.bytesPerSecond > 0) {
progressParts.push(`${formatBytes(value.bytesPerSecond)}/s`);
}

const failedAttempt = typeof lastInstallFailed?.attempt === "number"
? lastInstallFailed.attempt
: null;
const lastInstallFailedLine = lastInstallFailed
// renderKeyValues truncates each value at 96 columns, so this has to stay
// short enough that the retry hint survives a long version string.
? `${asString(lastInstallFailed.targetVersion) ?? "unknown"} did not land`
+ `${failedAttempt != null ? ` · attempt ${failedAttempt}` : ""}`
+ " · relaunched on old version — retry: ade update install"
: null;

const parkedLine = parked
? `${asString(parked.reason) ?? "unknown reason"}${
formatEpochTimestamp(parked.at) ? ` at ${formatEpochTimestamp(parked.at)}` : ""
}`
: null;

const recentlyInstalledLine = recentlyInstalled
? `${asString(recentlyInstalled.version) ?? "unknown version"}${
asString(recentlyInstalled.installedAt)
? ` · ${relativeTime(asString(recentlyInstalled.installedAt)!)}`
: ""
}`
: null;

const errorLine = asString(value.error);
const errorDetailLine = errorDetails
? `${asString(errorDetails.kind) ?? "error"} during ${
asString(errorDetails.phase) ?? "unknown phase"
}${errorDetails.preservesDownload === true ? " · download preserved" : ""}`
: null;

return renderKeyValues("ADE update", [
["status", value.status],
["current version", value.currentVersion],
["latest known", value.latestKnownVersion],
["update version", value.version],
["progress", progressParts.length ? progressParts.join(" · ") : null],
["release notes", value.releaseNotesUrl],
["last install failed", lastInstallFailedLine],
["parked", parkedLine],
["recently installed", recentlyInstalledLine],
["auto-apply at", formatEpochTimestamp(autoApplyPending?.deadlineAt)],
["auto-apply suppressed until", formatEpochTimestamp(value.autoApplySuppressedUntil)],
["error", errorLine],
["error detail", errorDetailLine],
]);
}

function formatLastFailureLine(report: AdeLastFailureReport): string {
const repeat = report.count > 1 ? ` x${report.count}` : "";
const scope = report.projectRoot ? ` [${report.projectRoot}]` : "";
Expand Down Expand Up @@ -18440,6 +18537,8 @@ function formatTextOutput(
return formatStorageCompression(value);
case "storage-maintenance":
return formatStorageMaintenance(value);
case "update-status":
return formatUpdateStatus(value);
case "action-result":
default:
if (isRecord(value))
Expand Down Expand Up @@ -18558,6 +18657,7 @@ function inferFormatter(
if (label === "history commits") return "history-commits";
if (label === "history show") return "history-show";
if (label === "actions list") return "actions-list";
if (label === "update status") return "update-status";
if (label.endsWith("actions")) return "actions-list";
const firstStep = plan.steps[0];
const params = typeof firstStep?.params === "object" && firstStep.params != null
Expand Down
15 changes: 14 additions & 1 deletion apps/ade-cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,20 @@ function syncPortRow(input: DoctorInput): DoctorRow {
label: "Sync port",
status: "warn",
detail: `bound on ${input.syncPort} instead of 8787${
holders.length ? ` · base holders: ${holders.join("; ")}` : " · first three base ports have no visible holders"
holders.length
? ` · base holders: ${holders.join("; ")}`
// "No visible holders" reads as "the ports are free", which is exactly
// the wrong conclusion: the holder is usually tailscaled, and it runs
// as root so this probe cannot see it. Point at the check that can.
: " · no holders visible to this user (a root-owned holder such as"
+ " tailscaled is invisible here — check `tailscale serve status`"
+ " and `netstat -an -p tcp`)"
}${
// The usual cause is ADE's own stranded `tailscale serve` entries from
// earlier runs. The host now reclaims those on its next publish, so the
// fix is a brain restart, not 60-odd manual `serve --tcp=N off` calls.
holders.length ? "" : " · ADE reclaims its own stale serve entries on the"
+ " next publish; `ade brain restart` should return it to 8787"
}`,
};
}
Expand Down
80 changes: 80 additions & 0 deletions apps/ade-cli/src/services/sync/syncHostService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
recordChatEventInReplayBuffer,
resolveSyncHostInboundProjectScope,
selectChangesetBatchChunk,
staleAdeTailnetServePorts,
syncConnectionTransportForOrigin,
} from "./syncHostService";
import { createBrainProjectActionsSyncHandler } from "./brainProjectActionsSyncHandler";
Expand Down Expand Up @@ -130,6 +131,51 @@ type BonjourPublishArgs = {
disableIPv6: boolean;
};

// Regression: `tailscale serve --bg` outlives the process that registered it,
// but ADE tracked the served port in memory only. Every restart -- and every
// force-kill that skipped teardown -- orphaned the previous entry, which stayed
// bound on the tailnet address and made ADE's own next wildcard bind fail
// EADDRINUSE against its own leftovers. It walked one port higher and leaked
// another, ratcheting forever: 66 stranded ports and ~70 failed binds per start
// on one machine.
describe("staleAdeTailnetServePorts", () => {
const serveStatus = (ports: Record<string, string>) =>
JSON.stringify({
TCP: Object.fromEntries(
Object.entries(ports).map(([port, forward]) => [port, { TCPForward: forward }]),
),
});

it("reclaims ADE's own stranded ports and keeps the live one", () => {
const json = serveStatus({
"8787": "127.0.0.1:8787",
"8788": "127.0.0.1:8788",
"8852": "127.0.0.1:8852",
});
expect(staleAdeTailnetServePorts(json, 8852)).toEqual([8787, 8788]);
});

it("leaves a hand-rolled serve in the same range alone", () => {
const json = serveStatus({
// Same port range, but forwarding somewhere ADE never would.
"8790": "127.0.0.1:3000",
"8791": "192.168.1.5:8791",
"8792": "127.0.0.1:8792",
});
expect(staleAdeTailnetServePorts(json, 8852)).toEqual([8792]);
});

it("ignores ports outside ADE's sync range", () => {
const json = serveStatus({ "443": "127.0.0.1:443", "9100": "127.0.0.1:9100" });
expect(staleAdeTailnetServePorts(json, 8852)).toEqual([]);
});

it("returns nothing for unparseable or empty status", () => {
expect(staleAdeTailnetServePorts("not json", 8852)).toEqual([]);
expect(staleAdeTailnetServePorts(JSON.stringify({}), 8852)).toEqual([]);
});
});

describe("resolveSyncHostInboundProjectScope", () => {
it("keeps runtime-scoped envelopes projectless", () => {
expect(resolveSyncHostInboundProjectScope("hello", "project-1", "project-1")).toEqual({
Expand Down Expand Up @@ -7133,6 +7179,40 @@ describe("sync host reliability guards", () => {
} as unknown as Parameters<typeof createSyncHostService>[0]);
}

// The relay readiness self-probe bridges into the sync host and disconnects
// without ever speaking the protocol, on every poll. Logging that at info
// made routine probe traffic indistinguishable at a glance from a peer that
// tried to authenticate and was rejected.
it("logs a peer that closed without sending a frame at debug", async () => {
const { projectRoot, cleanup } = createTempProjectRoot();
const logger = createDiscoveryLogger();
const host = createReliabilityHost(projectRoot, { logger });
try {
const port = await host.waitUntilListening();
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
});
ws.close(4000, "self probe complete");

await vi.waitFor(() => expect(logger.debug).toHaveBeenCalledWith(
"sync_host.peer_closed_without_frames",
expect.objectContaining({
authenticated: false,
reason: "self probe complete",
}),
));
expect(logger.info).not.toHaveBeenCalledWith(
"sync_host.peer_closed",
expect.anything(),
);
} finally {
await host.dispose();
cleanup();
}
});

it("serializes project switch handling without deadlocking later peer messages", async () => {
const { projectRoot, cleanup } = createTempProjectRoot();
const project = createDiscoveryProject({
Expand Down
Loading