diff options
| author | user <user@clank> | 2026-08-12 23:49:19 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 23:49:19 +0200 |
| commit | acf61609ee7b99138251cea879d593d5f1689392 (patch) | |
| tree | 06f880e8c2e312e1a600c4ee0a63943c29eb902f /cli/src/commands | |
| parent | release: publish sl0ppi.sl0p.foo, and a script to keep it current (diff) | |
| download | sl0ppi-acf61609ee7b99138251cea879d593d5f1689392.tar.gz sl0ppi-acf61609ee7b99138251cea879d593d5f1689392.tar.xz sl0ppi-acf61609ee7b99138251cea879d593d5f1689392.zip | |
cli: survive a dead session, and a flaky terminal
Three bugs found on a fresh Debian box, all of which made sl0ppi look broken
when the terminal underneath it was the problem.
1. JSON crash on startup. `action list-panes --json` against a session that has
died exits 0 with EMPTY stdout, and JSON.parse("") threw "JSON Parse error:
Unexpected EOF" at the user. Empty output is now "nothing", and genuinely
malformed output is reported with the command name and the offending text.
2. `up` silently resurrected. A dead-but-resurrectable session is not an empty
slot: `attach --create-background NAME` restores it from the serialised copy
and IGNORES --layout. The result was a root full of projects coming back as
plain shells with no agents and no purposes -- and the resurrected background
session then exited again within seconds. sl0ppi now refuses to guess and
explains the two real options (`up --fresh` to rebuild, `sl0ppij attach` to
restore it properly in a terminal). Verified on a clean slate that a fresh
`up` produces exactly the right panes: purpose='agent:main' held=true cmd='pi'.
3. The terminal's CLI actions are racy. Measured against a LIVE session on a
2-vCPU box: list-tabs and list-panes each fail ~60% of the time while the
session is detached, ~25% with a client attached, either with empty output or
"There is no active session!" (the action client consults
get_active_session() before it looks at -s). That is a defect in sl0ppij --
filed as a known issue -- but these are idempotent reads, so sl0ppi retries
them with backoff and distinguishes "the session is gone" from "the terminal
would not answer". `sl0ppi ls` went from failing most runs to 8/8.
Also:
- session creation returns before the server is registered, so `up` now waits
for readiness instead of racing its own next command
- UserError: actionable answers ("no project foo", "that session is a saved
dead copy") print and exit 1 instead of falling through to sl0ppij, which
buried the explanation and started a session nobody asked for
- `sl0ppi print <project>` implemented; it was referenced by the init template
and the design doc but did not exist
Diffstat (limited to 'cli/src/commands')
| -rw-r--r-- | cli/src/commands/workspace.ts | 53 |
1 files changed, 48 insertions, 5 deletions
diff --git a/cli/src/commands/workspace.ts b/cli/src/commands/workspace.ts index fe7d9de..bc5d7d2 100644 --- a/cli/src/commands/workspace.ts +++ b/cli/src/commands/workspace.ts @@ -13,6 +13,7 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { checkFragment } from "./doctor.ts"; import { discoverProjects, LAYOUT_FILE, MARKER_DIR } from "../core/discover.ts"; +import { UserError } from "../core/errors.ts"; import { sessionLayoutKdl, singleTabLayoutKdl, type TabSpec } from "../core/layout.ts"; import { isNoOp, planTabs, type Plan } from "../core/plan.ts"; import { projectPurpose } from "../core/purpose.ts"; @@ -24,6 +25,8 @@ export interface Context { client: Sl0ppij; depth: number; dryRun: boolean; + /** Discard a dead session of the same name instead of resurrecting it. */ + fresh?: boolean; log: (message: string) => void; } @@ -96,12 +99,39 @@ export function up(ctx: Context): { plan: Plan; created: boolean } { const client = ctx.client.forSession(session); if (!rootExists(root)) { - throw new Error(`root does not exist: ${root}`); + throw new UserError(`root does not exist: ${root}`); } const projects = discoverProjects(root, { depth: ctx.depth }); if (!client.sessionExists(session)) { + // A dead session of this name is not an empty slot: `attach + // --create-background NAME` RESURRECTS it from the serialised copy and + // silently ignores --layout. Observed consequences, in order of nastiness: + // the tabs come back without pane purposes or commands (so no agents), and + // the resurrected background session then exits again within seconds. + // + // So sl0ppi refuses to guess. Resurrecting is still available -- from + // sl0ppij itself, in a terminal, which is where it actually works. + const resurrectable = client.deadSessions().includes(session); + if (resurrectable && !ctx.fresh) { + throw new UserError( + [ + `session "${session}" already exists as a saved (dead) copy.`, + ``, + ` sl0ppi up --fresh discard it and rebuild from your project layouts`, + ` sl0ppij attach ${session}${" ".repeat(Math.max(1, 16 - session.length))}restore it as it was, in a terminal`, + ``, + `sl0ppi will not resurrect it for you: restoring in the background brings`, + `the tabs back without their agents, and the session exits again seconds later.`, + ].join("\n"), + ); + } + if (resurrectable && ctx.fresh) { + if (!ctx.dryRun) client.deleteSession(session); + ctx.log(`discarded the saved copy of ${session}`); + } + const { specs, failures } = usableSpecs(root, projects); for (const failure of failures) ctx.log(`! ${failure}`); if (ctx.dryRun) { @@ -120,6 +150,13 @@ export function up(ctx: Context): { plan: Plan; created: boolean } { writeFileSync(layoutPath, layout); client.createBackground(session, layoutPath); rememberRoot(session, root); + + // Creation returns before the server is registered; without this, the very + // next query fails with "There is no active session!". + if (!client.waitForSession(session)) { + throw new Error(`session ${session} did not come up`); + } + ctx.log(`created session ${session} (${specs.length} project tab(s))`); return { plan: emptyPlan(), created: true }; } @@ -227,7 +264,7 @@ export function open(ctx: Context, name: string): TabInfo { const relPath = resolveProjectName(ctx, name); if (!client.sessionExists(session)) { - throw new Error(`no session ${session}; run \`sl0ppi up\` first`); + throw new UserError(`no session ${session}; run \`sl0ppi up\` first`); } const purpose = projectPurpose(relPath); @@ -250,7 +287,7 @@ export function close(ctx: Context, name: string): void { const client = ctx.client.forSession(session); const purpose = projectPurpose(resolveProjectName(ctx, name)); const tab = client.listTabs().find((t) => t.purpose === purpose); - if (!tab) throw new Error(`no open tab for ${name}`); + if (!tab) throw new UserError(`no open tab for ${name}`); client.closeTab(tab.position); ctx.log(`- ${tab.name}`); } @@ -275,11 +312,17 @@ export function resolveProjectName(ctx: Context, name: string): string { const matches = projects.filter((p) => p.startsWith(name) || basename(p) === name); if (matches.length === 1) return matches[0] as string; if (matches.length > 1) { - throw new Error(`ambiguous: ${name} matches ${matches.join(", ")}`); + throw new UserError(`ambiguous: ${name} matches ${matches.join(", ")}`); } - throw new Error(`no project ${name} under ${root}`); + throw new UserError(`no project ${name} under ${root}`); } function emptyPlan(): Plan { return { create: [], close: [], keep: [], foreign: [] }; } + +/** The layout sl0ppi would use for a project — for eyeballing and debugging. */ +export function print(ctx: Context, name: string): string { + const relPath = resolveProjectName(ctx, name); + return singleTabLayoutKdl(specFor(ctx.resolved.root, relPath)); +} |
