feat(fleet): F3-m2 — native Pi heartbeat + model surface + mosaic_mission_status tool #602

Merged
jason.woltje merged 4 commits from feat/f3-m2-pi-harness into main 2026-06-22 01:43:19 +00:00
2 changed files with 95 additions and 5 deletions
Showing only changes of commit 56e5c35678 - Show all commits

View File

@@ -9,8 +9,15 @@
* 4. Memory routing — remind agent to use ~/.config/mosaic/memory/ * 4. Memory routing — remind agent to use ~/.config/mosaic/memory/
*/ */
import type { ExtensionAPI } from '@mariozechner/pi-coding-agent'; import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'; import {
existsSync,
readFileSync,
writeFileSync,
unlinkSync,
mkdirSync,
renameSync,
} from 'node:fs';
import { join, basename } from 'node:path'; import { join, basename } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { execSync, spawnSync } from 'node:child_process'; import { execSync, spawnSync } from 'node:child_process';
@@ -25,6 +32,57 @@ const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mo
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Native heartbeat (fleet R14/R15)
// ---------------------------------------------------------------------------
// When this agent runs under the Mosaic fleet (MOSAIC_AGENT_NAME set), the
// extension writes its OWN heartbeat in the same .hb contract `fleet ps` reads
// (ts/pid/status[/model]) and touches a `.hb.native` precedence marker so the
// shell sidecar defers. Native HB knows the real turn state (busy/ok), so it is
// more accurate than the pane-PID-only sidecar fallback.
const HB_AGENT_NAME = process.env['MOSAIC_AGENT_NAME'] ?? '';
const HB_RUN_DIR = process.env['MOSAIC_HEARTBEAT_RUN_DIR'] ?? join(MOSAIC_HOME, 'fleet', 'run');
const HB_INTERVAL_MS = (() => {
const s = Number.parseInt(process.env['MOSAIC_HEARTBEAT_INTERVAL'] ?? '', 10);
return Number.isFinite(s) && s > 0 ? s * 1000 : 15_000;
})();
function nativeHbEnabled(): boolean {
return HB_AGENT_NAME.length > 0;
}
function readModelId(ctx: ExtensionContext): string | null {
const m = ctx.model as unknown as { id?: string; name?: string } | undefined;
return m?.id ?? m?.name ?? null;
}
function writeNativeHeartbeat(status: 'ok' | 'busy', model: string | null): void {
if (!nativeHbEnabled()) return;
try {
mkdirSync(HB_RUN_DIR, { recursive: true });
const hb = join(HB_RUN_DIR, `${HB_AGENT_NAME}.hb`);
const lines = [`ts=${nowIso()}`, `pid=${process.pid}`, `status=${status}`];
if (model) lines.push(`model=${model}`);
const tmp = `${hb}.tmp.${process.pid}`;
writeFileSync(tmp, lines.join('\n') + '\n');
renameSync(tmp, hb); // atomic replace — fleet ps never reads a partial file
// Precedence marker: tells the shell sidecar that native HB is authoritative.
writeFileSync(join(HB_RUN_DIR, `${HB_AGENT_NAME}.hb.native`), nowIso() + '\n');
} catch {
// Best-effort: never let heartbeat I/O disrupt the Pi session.
}
}
function clearNativeMarker(): void {
if (!nativeHbEnabled()) return;
try {
const m = join(HB_RUN_DIR, `${HB_AGENT_NAME}.hb.native`);
if (existsSync(m)) unlinkSync(m); // native stopping — let the sidecar take over
} catch {
/* ignore */
}
}
function safeRead(filePath: string): string | null { function safeRead(filePath: string): string | null {
try { try {
return readFileSync(filePath, 'utf-8'); return readFileSync(filePath, 'utf-8');
@@ -187,6 +245,9 @@ function buildMissionSummary(cwd: string, mission: ActiveMission): string {
export default function register(pi: ExtensionAPI) { export default function register(pi: ExtensionAPI) {
let sessionCwd = process.cwd(); let sessionCwd = process.cwd();
let hbStatus: 'ok' | 'busy' = 'ok';
let hbModel: string | null = null;
let hbTimer: ReturnType<typeof setInterval> | null = null;
// ── Session Start ───────────────────────────────────────────────────── // ── Session Start ─────────────────────────────────────────────────────
pi.on('session_start', async (_event, ctx) => { pi.on('session_start', async (_event, ctx) => {
@@ -207,10 +268,39 @@ export default function register(pi: ExtensionAPI) {
} else { } else {
ctx.ui.notify('Mosaic framework loaded', 'info'); ctx.ui.notify('Mosaic framework loaded', 'info');
} }
// Native heartbeat: write immediately, then on an interval. Idle = 'ok';
// turn_start/turn_end flip the status so `fleet ps` reflects real activity.
if (nativeHbEnabled()) {
hbModel = readModelId(ctx);
writeNativeHeartbeat('ok', hbModel);
hbTimer = setInterval(() => writeNativeHeartbeat(hbStatus, hbModel), HB_INTERVAL_MS);
if (typeof hbTimer.unref === 'function') hbTimer.unref();
}
}); });
// ── Session End ─────────────────────────────────────────────────────── // ── Turn lifecycle → accurate busy/ok heartbeat ───────────────────────
pi.on('session_end', async (_event, _ctx) => { pi.on('turn_start', async (_event, ctx) => {
hbStatus = 'busy';
hbModel = readModelId(ctx) ?? hbModel;
writeNativeHeartbeat('busy', hbModel);
});
pi.on('turn_end', async (_event, ctx) => {
hbStatus = 'ok';
hbModel = readModelId(ctx) ?? hbModel;
writeNativeHeartbeat('ok', hbModel);
});
// ── Session Shutdown ──────────────────────────────────────────────────
// (The pi API event is 'session_shutdown'; the prior 'session_end' handler
// never fired — fixed here so repo hooks + lock cleanup actually run.)
pi.on('session_shutdown', async (_event, _ctx) => {
if (hbTimer) {
clearInterval(hbTimer);
hbTimer = null;
}
clearNativeMarker();
// Run repo session-end hook // Run repo session-end hook
runRepoHook(sessionCwd, 'session-end'); runRepoHook(sessionCwd, 'session-end');

View File

@@ -129,7 +129,7 @@ _start_heartbeat_sidecar() {
# references to any variables from this script's environment. # references to any variables from this script's environment.
local sidecar_script local sidecar_script
sidecar_script=$(printf \ sidecar_script=$(printf \
'hb=%q; pid=%q; iv=%q; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \ 'hb=%q; pid=%q; iv=%q; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do nat="$hb.native"; if [ -f "$nat" ] && [ "$(( $(date +%%s) - $(stat -c %%Y "$nat" 2>/dev/null || echo 0) ))" -lt "$(( iv * 2 ))" ]; then sleep "$iv"; continue; fi; tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \
"$hb_file" "$pane_pid" "$interval") "$hb_file" "$pane_pid" "$interval")
# setsid + disown ensures the sidecar survives this script exiting. # setsid + disown ensures the sidecar survives this script exiting.