Skip to content
Draft
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
158 changes: 147 additions & 11 deletions packages/extension/opencode-plugin/stack_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,20 +369,156 @@ function readFleetStatus(statusPath?: string): FleetStatusSummary | undefined {
};
}

/** Fleet posture state, written by the EXTENSION on attach-state transitions
* (#780). This plugin is a READ-ONLY consumer — no network probes, file
* reads only. Schema lives in src/fleet_attach_state.ts (single writer);
* this strict reader must stay in sync with it. */
function fleetAttachStateFile(override?: string): string {
if (override) return override;
const env = process.env.AMICO_FLEET_ATTACH_STATE;
if (env && env.trim() !== "") return env.trim();
return path.join(os.homedir(), ".amico", "ops", "fleet", "attach-state.json");
}

/** Stale beyond this → posture claims degrade to timestamped history, never
* current-tense. Kept in step with the issue's suggested 10 min. */
const ATTACH_STATE_TTL_MS = 10 * 60_000;

interface FleetAttachState {
hostname: string;
mode: "fleet" | "standalone" | "degraded";
hubName?: string;
hubBaseUrl?: string;
reachable: boolean;
lastOkAt?: string;
lastRttMs?: number;
since: string;
updatedAt: string;
}

type AttachStateRead =
| { state: FleetAttachState; fresh: boolean }
| { corrupt: true }
| undefined;

function readAttachState(override?: string): AttachStateRead {
let raw: unknown;
try {
raw = JSON.parse(fs.readFileSync(fleetAttachStateFile(override), "utf8"));
} catch {
return undefined; // missing file — the honest fallback is the role line
}
if (typeof raw !== "object" || raw === null) return { corrupt: true };
const o = raw as Record<string, unknown>;
const mode = o.mode;
if (mode !== "fleet" && mode !== "standalone" && mode !== "degraded") return { corrupt: true };
if (typeof o.hostname !== "string" || o.hostname === "") return { corrupt: true };
if (typeof o.reachable !== "boolean") return { corrupt: true };
if (typeof o.updatedAt !== "string" || Number.isNaN(Date.parse(o.updatedAt))) return { corrupt: true };
const updatedAt = o.updatedAt;
const state: FleetAttachState = {
hostname: o.hostname,
mode,
hubName: typeof o.hubName === "string" ? o.hubName : undefined,
hubBaseUrl: typeof o.hubBaseUrl === "string" ? o.hubBaseUrl : undefined,
reachable: o.reachable,
lastOkAt: typeof o.lastOkAt === "string" ? o.lastOkAt : undefined,
lastRttMs: typeof o.lastRttMs === "number" ? o.lastRttMs : undefined,
since: typeof o.since === "string" && !Number.isNaN(Date.parse(o.since)) ? o.since : updatedAt,
updatedAt,
};
return { state, fresh: Date.now() - Date.parse(updatedAt) <= ATTACH_STATE_TTL_MS };
}

function ageMinOf(iso: string | undefined): number | undefined {
if (!iso) return undefined;
const t = Date.parse(iso);
if (Number.isNaN(t)) return undefined;
return Math.max(0, Math.round((Date.now() - t) / 60000));
}

function ageStr(min: number): string {
if (min < 60) return `${min} min`;
if (min < 60 * 48) return `${Math.round(min / 60)} h`;
return `${Math.round(min / 1440)} d`;
}

/** Posture lines appended to the fleet section. Every claim is timestamped or
* explicitly unknown (constraint: never render "healthy" from stale data). */
function buildPostureLines(read: AttachStateRead): string[] {
if (!read) return [];
if ("corrupt" in read) {
return [
"- ⚠ Machine posture unknown: `attach-state.json` is corrupt/unreadable — treat any attached-to-hub claim as unverified.",
];
}
const { state, fresh } = read;
const hub =
[state.hubName, state.hubBaseUrl ? `\`${state.hubBaseUrl}\`` : ""].filter(Boolean).join(" ") ||
"(hub unspecified)";
if (!fresh) {
const updAge = ageMinOf(state.updatedAt);
const lastVerified =
state.mode === "fleet"
? `hub ${hub} reachable as of ${ageStr(updAge ?? 0)} ago`
: state.mode === "degraded"
? `hub ${hub} UNREACHABLE since ${ageStr(ageMinOf(state.since) ?? 0)} ago` +
(state.lastOkAt ? ` (last ok ${ageStr(ageMinOf(state.lastOkAt) ?? 0)} ago)` : "")
: `standalone since ${ageStr(ageMinOf(state.since) ?? 0)} ago`;
return [
`- ⚠ Machine posture is STALE (updated ${ageStr(updAge ?? 0)} ago, beyond the ${Math.round(ATTACH_STATE_TTL_MS / 60000)} min TTL) — current reachability UNKNOWN; the extension may not be running.`,
`- Last verified: ${lastVerified}.`,
];
}
const updAge = ageMinOf(state.updatedAt) ?? 0;
if (state.mode === "fleet") {
const rtt = state.lastRttMs !== undefined ? `, RTT ${state.lastRttMs} ms` : "";
return [
`- Posture: machine **${state.hostname}** attached to hub ${hub} — reachable ✓ — last ok ${ageStr(ageMinOf(state.lastOkAt) ?? updAge)} ago${rtt}, updated ${ageStr(updAge)} ago.`,
];
}
if (state.mode === "degraded") {
const lastOk = state.lastOkAt ? `; last ok ${ageStr(ageMinOf(state.lastOkAt) ?? 0)} ago` : "";
return [
`- Posture: machine **${state.hostname}** — hub ${hub} UNREACHABLE since ${ageStr(ageMinOf(state.since) ?? 0)} ago — degraded${lastOk}. Do not treat the hub as up.`,
];
}
return [
`- Posture: machine **${state.hostname}** — STANDALONE (no hub), went standalone ${ageStr(ageMinOf(state.since) ?? 0)} ago — sessions run locally; do not treat this machine as fleet-attached.`,
];
}

/** Lean fleet line + on-demand pointers (the reader's choice: detail loads
* from fleet-status.json / the fleet skill only when relevant). Absent
* fleet.json (standalone or no fleet tooling) → "" — nothing to say. */
function buildFleetSection(opts: { configPath?: string; statusPath?: string } = {}): string {
* fleet.json (standalone or no fleet tooling) → "" — nothing to say, unless
* a posture state file exists (#780: a standalone machine says so
* explicitly even with no role config). */
function buildFleetSection(opts: { configPath?: string; statusPath?: string; statePath?: string } = {}): string {
const role = readFleetRole(opts.configPath);
if (role === null) return "";

const roleText =
role === "server"
? "**server** — this machine is the canonical Amicode server"
: role === "client"
? "**client** — rides the tunnel to the canonical server"
: `**${role}**`;
const lines = [`## Fleet (live)`, `Role: ${roleText} (\`~/.amico/ops/fleet/fleet.json\`).`];
const posture = readAttachState(opts.statePath);
if (role === null && !posture) return "";

// Live truth wins: a fresh attach-state saying standalone overrides a stale
// "client" role line — never render a machine as attached when it is not.
let roleText: string | null;
if (posture && "state" in posture && posture.fresh && posture.state.mode === "standalone") {
roleText =
`**standalone** — NOT fleet-attached (attach-state.json overrides` +
(role ? ` fleet.json's "${role}" role line)` : " absent fleet.json)");
} else if (role !== null) {
roleText =
role === "server"
? "**server** — this machine is the canonical Amicode server"
: role === "client"
? "**client** — rides the tunnel to the canonical server"
: `**${role}**`;
} else {
roleText = null;
}

const lines = [`## Fleet (live)`];
if (roleText !== null) lines.push(`Role: ${roleText} (\`~/.amico/ops/fleet/fleet.json\`).`);
lines.push(...buildPostureLines(posture));

const status = readFleetStatus(opts.statusPath);
if (status) {
Expand Down
51 changes: 50 additions & 1 deletion packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
import { probeCommand, formatHealthReport, probeOpencodeTui, type HealthResult } from "./healthcheck";
import { fleetHealthReport, FLEET_GUARD_REL } from "./fleet_health";
import { isFleetClient, getFleetRole, goStandalone, readFleetConfig, migrateLegacyFallback } from "./fleet_fallback";
import { postureTransition, writeAttachState, readAttachState, recordStandalonePosture, standaloneHubFromConfig } from "./fleet_attach_state";
import { resolveHubTarget, restartHub } from "./hub_ops";
import { registerAmicodeTerminal } from "./terminal";
import { amicodeServiceDisposal, startAmicodeService } from "./amicode_service_wiring";
Expand Down Expand Up @@ -573,6 +574,27 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
if (binary !== undefined && fleetClient) {
const fleetCfg = readFleetConfig();
const fleetPort = fleetCfg?.canonical?.port ?? 4096;
// #780: the extension is the single writer of attach-state.json (the
// agent-context plugin only reads it). Writes happen on attach-state
// TRANSITIONS (attach / degrade / hub-lost / hub-regained), never per
// poll tick — steady-state claims stay timestamped at their transition.
const postureHub = {
name: fleetCfg?.canonical?.sshAlias ?? fleetCfg?.canonical?.host ?? "canonical",
baseUrl: `http://127.0.0.1:${fleetPort}`,
};
let posturePrev: ReturnType<typeof readAttachState> = readAttachState();
const recordClientPosture = (up: boolean, rttMs?: number): void => {
try {
const { changed, state } = postureTransition(posturePrev, { up, rttMs }, Date.now(), postureHub);
if (changed && state) {
writeAttachState(state);
posturePrev = state;
opencodeChannel.appendLine(`[fleet] posture: ${state.mode} (attach-state.json updated)`);
}
} catch (e) {
opencodeChannel.appendLine(`[fleet] posture write failed: ${(e as Error).message}`);
}
};
opencodeChannel.appendLine(`[fleet] client mode — guard ${binary} would refuse on ${os.hostname()} — riding tunnel 127.0.0.1:${fleetPort}`);
opencodeChannel.appendLine(`[fleet] hint: canonical offline? Palette → Amicode: Fleet — Go Standalone`);
// Distiller still arms on the client (uses vendored binary directly, not the guard)
Expand Down Expand Up @@ -613,12 +635,16 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
let fleetNotified = false;
const checkFleet = async () => {
fleetChecks++;
let up = false;
let rttMs: number | undefined;
try {
const t0 = Date.now();
const r = await fetch(`http://127.0.0.1:${fleetPort}/`, {
signal: AbortSignal.timeout(1500),
headers: serverAuthHeaders,
});
const up = r.ok || (r.status >= 200 && r.status < 400);
rttMs = Date.now() - t0;
up = r.ok || (r.status >= 200 && r.status < 400);
if (up && !fleetReady) {
fleetReady = true;
fleetNotified = false;
Expand Down Expand Up @@ -651,6 +677,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
else if (pick === `Show log`) opencodeChannel.show();
});
}
recordClientPosture(up, rttMs);
} catch {
if (fleetReady) {
fleetReady = false;
Expand All @@ -670,6 +697,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
else if (pick === `Show log`) opencodeChannel.show();
});
}
recordClientPosture(false);
}
};
fleetClientPoll = setInterval(() => void checkFleet(), 2000);
Expand Down Expand Up @@ -1319,6 +1347,18 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
// Migrate legacy fallback.json on activation.
migrateLegacyFallback();

// #780: a standalone machine (incl. never-fleeted) states its posture
// explicitly in agent context — record it at activation so the state file
// exists before any session asks. Client machines are owned by the attach
// loop; hub-server machines keep the plain role line (dev's call, minimal).
if (getFleetRole() === "standalone") {
try {
recordStandalonePosture({ hub: standaloneHubFromConfig(readFleetConfig()) });
} catch {
// best-effort — the plugin degrades honestly when the file is absent
}
}

const fleetStatusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99);
ctx.subscriptions.push(fleetStatusItem);
const refreshFleetStatus = (): void => {
Expand Down Expand Up @@ -1351,7 +1391,16 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
const cfg = vscode.workspace.getConfiguration("amicode");
const prevBinary = cfg.get<string>("opencodeBinary", "");
const prevPort = cfg.get<number>("opencodePort", 0);
// #780: leaving the fleet is an attach-state transition — persist it so the
// agent context names the machine as standalone with the fallback time.
// Hub identity is captured BEFORE the config write (goStandalone drops it).
const leavingHub = standaloneHubFromConfig(getFleetRole() === "client" ? readFleetConfig() : null);
goStandalone({ previousBinary: prevBinary, previousPort: prevPort });
try {
recordStandalonePosture({ hub: leavingHub });
} catch (e) {
opencodeChannel.appendLine(`[fleet] posture write failed: ${(e as Error).message}`);
}
try {
// Clear the fleet guard override → vendored binary, ephemeral port
await cfg.update("opencodeBinary", "", vscode.ConfigurationTarget.Global);
Expand Down
123 changes: 123 additions & 0 deletions packages/extension/src/fleet_attach_state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Machine-posture state for the agent-context plugin (#780) — the SINGLE
// WRITER of ~/.amico/ops/fleet/attach-state.json, written by the extension's
// fleet attach loop on attach-state TRANSITIONS only (attach, degrade,
// hub-lost, hub-regained — never per poll tick) and on standalone-mode
// changes. The plugin (opencode-plugin/stack_state.ts) is read-only: it
// renders the posture block from this file and does NO network probes.
//
// Schema (the plugin's strict reader in stack_state.ts must stay in sync):
// { hostname, mode: "fleet"|"standalone"|"degraded", hubName?, hubBaseUrl?,
// reachable, lastOkAt?, lastRttMs?, since, updatedAt } // ISO strings
//
// `since` = when the current mode was entered (the fallback time a standalone
// machine names); `lastOkAt` = last successful probe (preserved across
// degrade). `fleet.json` remains the role config — this file is live truth.

import * as fs from "node:fs";
import * as path from "node:path";
import { hostname } from "node:os";
import { FLEET_DIR } from "./fleet_fallback";

export const FLEET_ATTACH_STATE_PATH = path.join(FLEET_DIR, "attach-state.json");

export type FleetAttachMode = "fleet" | "standalone" | "degraded";

export interface FleetAttachState {
hostname: string;
mode: FleetAttachMode;
hubName?: string;
hubBaseUrl?: string;
reachable: boolean;
/** Last successful probe (ISO). Preserved when the hub degrades. */
lastOkAt?: string;
lastRttMs?: number;
/** When the current mode was entered (ISO) — the standalone fallback time. */
since: string;
updatedAt: string;
}

/** Previous posture from disk. Corrupt or missing → undefined (unknown). */
export function readAttachState(p: string = FLEET_ATTACH_STATE_PATH): FleetAttachState | undefined {
try {
const parsed = JSON.parse(fs.readFileSync(p, "utf8")) as FleetAttachState;
if (parsed && typeof parsed === "object" && typeof parsed.mode === "string") return parsed;
return undefined;
} catch {
return undefined;
}
}

/** Atomic write (tmp + rename) — the plugin may read concurrently. */
export function writeAttachState(state: FleetAttachState, p: string = FLEET_ATTACH_STATE_PATH): void {
fs.mkdirSync(path.dirname(p), { recursive: true });
const tmp = `${p}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n");
fs.renameSync(tmp, p);
}

/** The transition decision for the client attach loop: a write is due ONLY
* when the attach state changes (unknown→attach, attach→degraded,
* degraded→attach). Steady fleet or steady degraded = no write — the file's
* claims stay timestamped at their transition. A corrupt/missing previous
* state is treated as unknown (so the first probe after corruption writes). */
export function postureTransition(
prev: FleetAttachState | "corrupt" | undefined,
probe: { up: boolean; rttMs?: number },
nowMs: number,
hub: { name?: string; baseUrl?: string } = {},
host: string = hostname(),
): { changed: boolean; state?: FleetAttachState } {
const prevMode = prev && prev !== "corrupt" ? prev.mode : undefined;
const mode: FleetAttachMode = probe.up ? "fleet" : "degraded";
if (prevMode === mode) return { changed: false };
const now = new Date(nowMs).toISOString();
const state: FleetAttachState = {
hostname: host,
mode,
...(hub.name ? { hubName: hub.name } : {}),
...(hub.baseUrl ? { hubBaseUrl: hub.baseUrl } : {}),
reachable: probe.up,
...(probe.up
? { lastOkAt: now, ...(probe.rttMs !== undefined ? { lastRttMs: probe.rttMs } : {}) }
: prev && prev !== "corrupt" && prev.lastOkAt
? { lastOkAt: prev.lastOkAt, ...(prev.lastRttMs !== undefined ? { lastRttMs: prev.lastRttMs } : {}) }
: {}),
since: now,
updatedAt: now,
};
return { changed: true, state };
}

/** Write (or refresh) the standalone posture. A machine already standalone
* keeps its original `since` — the fallback time must not drift across
* extension restarts. A transition out of fleet/degraded (Go Standalone)
* stamps since=now. */
export function recordStandalonePosture(
opts: { path?: string; hostname?: string; nowMs?: number; hub?: { name?: string; baseUrl?: string } } = {},
): FleetAttachState {
const p = opts.path ?? FLEET_ATTACH_STATE_PATH;
const nowMs = opts.nowMs ?? Date.now();
const now = new Date(nowMs).toISOString();
const prev = readAttachState(p);
const state: FleetAttachState = {
hostname: opts.hostname ?? hostname(),
mode: "standalone",
...(opts.hub?.name ? { hubName: opts.hub.name } : {}),
...(opts.hub?.baseUrl ? { hubBaseUrl: opts.hub.baseUrl } : {}),
reachable: false,
...(prev?.mode === "standalone" && prev.since ? { since: prev.since } : { since: now }),
updatedAt: now,
};
writeAttachState(state, p);
return state;
}

// Default-path convenience for callers that need the standalone fallback hub
// identity from the (possibly stale) fleet config.
export function standaloneHubFromConfig(cfg: { canonical?: { host?: string; port?: number; sshAlias?: string } } | null): { name?: string; baseUrl?: string } {
const name = cfg?.canonical?.sshAlias ?? cfg?.canonical?.host;
return {
...(name ? { name } : {}),
...(cfg?.canonical?.port ? { baseUrl: `http://127.0.0.1:${cfg.canonical.port}` } : {}),
};
}
Loading
Loading