diff --git a/shell/src/main/scala/orca/shell/actions/SessionAction.scala b/shell/src/main/scala/orca/shell/actions/SessionAction.scala index fadf0d53..c9daaca7 100644 --- a/shell/src/main/scala/orca/shell/actions/SessionAction.scala +++ b/shell/src/main/scala/orca/shell/actions/SessionAction.scala @@ -43,8 +43,8 @@ private[shell] object SessionAction: val name = SessionNaming.displayName(session) val stage = session.stage.fold("")(s => s", stage '$s'") val branch = selection.manifest.branch.fold("")(b => s", on branch '$b'") - val crashedSuffix = if selection.crashed then " (crashed)" else "" - s"resuming session '$name' [$harnessName]$stage$branch, in ${selection.manifest.workDir}$crashedSuffix" + val crashed = SessionNaming.crashedSuffix(selection.observedStatus) + s"resuming session '$name' [$harnessName]$stage$branch, in ${selection.manifest.workDir}$crashed" /** Parses the manifest's stored `workDir` and confirms it's still a directory * — a checkout deleted after its run finished otherwise crashes resume: diff --git a/shell/src/main/scala/orca/shell/cli/Cli.scala b/shell/src/main/scala/orca/shell/cli/Cli.scala index 68944cd1..62955718 100644 --- a/shell/src/main/scala/orca/shell/cli/Cli.scala +++ b/shell/src/main/scala/orca/shell/cli/Cli.scala @@ -13,7 +13,7 @@ import org.jline.terminal.Terminal import orca.RawArgs import orca.shell.{ShellEnv, Tier, WorktreeScan} import orca.shell.run.LaunchResult -import orca.shell.sessions.ManifestReader +import orca.shell.sessions.ObservedStatus import orca.shell.ui.ShellUi import orca.subprocess.TtyProbe @@ -309,7 +309,7 @@ private[cli] class CliCommands(using env: ShellEnv): list.value, json.value, isTty, - ManifestReader.pidAlive + ObservedStatus.processAlive ) @main(doc = diff --git a/shell/src/main/scala/orca/shell/cli/ContinueCli.scala b/shell/src/main/scala/orca/shell/cli/ContinueCli.scala index 05c43f1f..627d9131 100644 --- a/shell/src/main/scala/orca/shell/cli/ContinueCli.scala +++ b/shell/src/main/scala/orca/shell/cli/ContinueCli.scala @@ -1,5 +1,6 @@ package orca.shell.cli +import orca.runner.manifest.AttemptManifest import orca.shell.ScanDirs import orca.shell.actions.SessionAction import orca.shell.sessions.{ @@ -17,10 +18,10 @@ import Cli.{actionFailure, complete, requireTty, usageFailure, withTerminal} */ private[cli] object ContinueCli: - /** `continue`'s full behavior over explicit `dirs`/`tty`/`pidAlive` (test + /** `continue`'s full behavior over explicit `dirs`/`tty`/`processAlive` (test * seam) — tests seed each directory with `.orca/cache/attempts/` manifests, - * simulate a terminal or a pipe via `tty`, and fake pid liveness via - * `pidAlive`. The directories arrive resolved + * simulate a terminal or a pipe via `tty`, and fake process liveness via + * `processAlive`. The directories arrive resolved * ([[orca.shell.WorktreeScan.dirs]], at the real entry point), so nothing * here spawns git. */ @@ -30,10 +31,10 @@ private[cli] object ContinueCli: list: Boolean, json: Boolean, tty: Boolean, - pidAlive: Long => Boolean + processAlive: AttemptManifest => Boolean ): Int = val AttemptListing(attempts, warnings) = - ManifestReader.list(dirs.own, dirs.worktrees, pidAlive) + ManifestReader.list(dirs.own, dirs.worktrees, processAlive) warnings.foreach(Cli.diagnostic) val index = SessionIndex.of(attempts) if list then diff --git a/shell/src/main/scala/orca/shell/cli/Tables.scala b/shell/src/main/scala/orca/shell/cli/Tables.scala index 7143dbb7..02dd3f97 100644 --- a/shell/src/main/scala/orca/shell/cli/Tables.scala +++ b/shell/src/main/scala/orca/shell/cli/Tables.scala @@ -12,7 +12,12 @@ import orca.StagePath import orca.runner.manifest.SessionKind import orca.shell.flows.DiscoveredFlow import orca.settings.AgentSpec -import orca.shell.sessions.{ResumeCommand, SessionIndex, SessionNaming} +import orca.shell.sessions.{ + ObservedStatus, + ResumeCommand, + SessionIndex, + SessionNaming +} /** The CLI's table/JSON rendering (ADR 0021 §10) — the row shapes `list` and * `continue --list` emit, their jsoniter codecs, and the shared space-padded @@ -50,8 +55,13 @@ private[cli] object Tables: lastActiveAt: String, resumable: Boolean, reason: Option[String], - crashed: Boolean + /** The attempt's status, `Crashed` included. */ + attemptStatus: ObservedStatus ) + private given ConfiguredJsonValueCodec[ObservedStatus] = + ConfiguredJsonValueCodec.derived[ObservedStatus](using + CodecMakerConfig.withDiscriminatorFieldName(None) + ) // `withTransientEmpty`/`withTransientNone` false: `--json` output is for // scripts, which should see an always-present `reason` key (null when // unset) and `shadows`/similar fields rather than a silently vanishing one. @@ -76,7 +86,7 @@ private[cli] object Tables: lastActiveAt = session.lastActiveAt.toString, resumable = gate.isRight, reason = gate.left.toOption, - crashed = selection.crashed + attemptStatus = selection.observedStatus ) private[cli] def printSessionListing( @@ -94,7 +104,7 @@ private[cli] object Tables: if r.resumable then "" else s" not resumable: ${r.reason.getOrElse("")}" val sessionName = - r.sessionName + (if r.crashed then " (crashed)" else "") + + r.sessionName + SessionNaming.crashedSuffix(r.attemptStatus) + tag(r.workDir, r.branch) ( r.id, diff --git a/shell/src/main/scala/orca/shell/menu/ShellMenu.scala b/shell/src/main/scala/orca/shell/menu/ShellMenu.scala index 400a16b3..37dfe6b4 100644 --- a/shell/src/main/scala/orca/shell/menu/ShellMenu.scala +++ b/shell/src/main/scala/orca/shell/menu/ShellMenu.scala @@ -8,6 +8,7 @@ import orca.shell.run.FlowLauncher import orca.shell.sessions.{ AttemptListing, ManifestReader, + ObservedStatus, SessionIndex, SessionPicker } @@ -49,7 +50,7 @@ private[shell] object ShellMenu: ManifestReader.list( scanDirs.own, scanDirs.worktrees, - ManifestReader.pidAlive + ObservedStatus.processAlive ) warnings.foreach(ShellOutput.info) val continueSessionCount = diff --git a/shell/src/main/scala/orca/shell/sessions/ManifestReader.scala b/shell/src/main/scala/orca/shell/sessions/ManifestReader.scala index 9f181054..376e9121 100644 --- a/shell/src/main/scala/orca/shell/sessions/ManifestReader.scala +++ b/shell/src/main/scala/orca/shell/sessions/ManifestReader.scala @@ -1,20 +1,19 @@ package orca.shell.sessions import orca.{AttemptId, OrcaDir} -import orca.runner.manifest.{AttemptStatus, AttemptManifest} +import orca.runner.manifest.AttemptManifest import orca.util.JsonFile import scala.util.control.NonFatal -/** A manifest paired with its attempt's id (from the file name) and whether the - * attempt is now known to have crashed (status [[AttemptStatus.Running]] with - * a dead pid, ADR 0021 §8) — computed once here rather than re-derived by - * every caller. +/** A manifest paired with its attempt's id (from the file name) and its + * [[ObservedStatus]] — read that rather than `manifest.status`, which cannot + * tell a crashed attempt from a running one. */ private[shell] case class RecordedAttempt( id: AttemptId, manifest: AttemptManifest, - crashed: Boolean + observedStatus: ObservedStatus ) /** What [[ManifestReader.list]] found: the continuable attempts, newest first, @@ -46,23 +45,25 @@ private[shell] object ManifestReader: * parameters rather than one list, because that is the whole difference * between them. * - * A manifest with [[AttemptStatus.Running]] whose `pid` is no longer alive - * is a crashed attempt — its sessions are still offered, per ADR 0021 §8. An - * attempt that committed no session is left out: it has nothing to continue. - * Each directory's `.orca/cache/attempts/` is read passively - * ([[OrcaDir.attemptsPath]], not [[OrcaDir.ensureAttempts]]) — absent or - * empty contributes nothing and creates nothing on disk. A file that fails - * to parse as JSON, or doesn't match the `AttemptManifest` schema — which - * includes a timestamp that isn't an `Instant` — is skipped with a warning - * naming the file rather than aborting the whole listing. + * A crashed attempt ([[ObservedStatus.Crashed]], decided by `processAlive`; + * [[ObservedStatus.processAlive]] in production) still has its sessions + * offered, per ADR 0021 §8. An attempt that committed no session is left + * out: it has nothing to continue. Each directory's `.orca/cache/attempts/` + * is read passively ([[OrcaDir.attemptsPath]], not + * [[OrcaDir.ensureAttempts]]) — absent or empty contributes nothing and + * creates nothing on disk. A file that fails to parse as JSON, or doesn't + * match the `AttemptManifest` schema — which includes a timestamp that isn't + * an `Instant` — is skipped with a warning naming the file rather than + * aborting the whole listing. */ def list( own: os.Path, otherWorktrees: List[os.Path], - pidAlive: Long => Boolean + processAlive: AttemptManifest => Boolean ): AttemptListing = val perDir = - readAttemptsDir(own, pidAlive) :: otherWorktrees.map(guarded(_, pidAlive)) + readAttemptsDir(own, processAlive) :: + otherWorktrees.map(guarded(_, processAlive)) AttemptListing( perDir.flatMap(_.attempts).sortBy(_.manifest.startedAt).reverse, perDir.flatMap(_.warnings) @@ -70,9 +71,9 @@ private[shell] object ManifestReader: private def guarded( workDir: os.Path, - pidAlive: Long => Boolean + processAlive: AttemptManifest => Boolean ): AttemptListing = - try readAttemptsDir(workDir, pidAlive) + try readAttemptsDir(workDir, processAlive) catch case NonFatal(e) => AttemptListing(Nil, List(s"skipping $workDir: ${firstLine(e)}")) @@ -88,7 +89,7 @@ private[shell] object ManifestReader: */ private def readAttemptsDir( workDir: os.Path, - pidAlive: Long => Boolean + processAlive: AttemptManifest => Boolean ): AttemptListing = val dir = OrcaDir.attemptsPath(workDir) OrcaDir.assertNoOrcaSymlinks(workDir, dir) @@ -98,20 +99,12 @@ private[shell] object ManifestReader: os.list(dir).filter(OrcaDir.isManifest).toList.map(readManifest) val attempts = results.collect: case Right((id, m)) if m.continuable => - RecordedAttempt(id, m, crashed(m, pidAlive)) + RecordedAttempt(id, m, ObservedStatus.of(m, processAlive)) AttemptListing( attempts, results.collect { case Left(warning) => warning } ) - private def crashed( - manifest: AttemptManifest, - pidAlive: Long => Boolean - ): Boolean = - manifest.status match - case AttemptStatus.Running => !pidAlive(manifest.pid) - case AttemptStatus.Succeeded | AttemptStatus.Failed => false - /** A missing file is a warning too: the listing saw it and the read found * nothing, which for a directory the shell does not control is a race with * pruning or removal worth a line. @@ -132,12 +125,3 @@ private[shell] object ManifestReader: case JsonFile.Read.Unreadable(reason) => Left(s"skipping $file: $reason") case JsonFile.Read.Corrupt(reason) => Left(s"skipping $file: $reason") - - /** The production value of [[list]]'s `pidAlive` parameter (ADR 0021 §8): - * `ProcessHandle.of` finds nothing for a pid that's been reaped — treated as - * not alive, same as a live handle reporting `isAlive == false`. Shared by - * the interactive menu and the CLI's `continue`, so both derive an attempt's - * crashed status the same way. - */ - private[shell] def pidAlive(pid: Long): Boolean = - ProcessHandle.of(pid).map[Boolean](_.isAlive).orElse(false) diff --git a/shell/src/main/scala/orca/shell/sessions/ObservedStatus.scala b/shell/src/main/scala/orca/shell/sessions/ObservedStatus.scala new file mode 100644 index 00000000..5c985c02 --- /dev/null +++ b/shell/src/main/scala/orca/shell/sessions/ObservedStatus.scala @@ -0,0 +1,47 @@ +package orca.shell.sessions + +import orca.runner.manifest.{AttemptManifest, AttemptStatus} + +import java.time.Duration +import scala.jdk.OptionConverters.* + +/** An attempt's [[AttemptStatus]] as the shell sees it now: a manifest still + * [[AttemptStatus.Running]] whose process is gone is `Crashed` (ADR 0021 §8). + */ +private[shell] enum ObservedStatus: + case Running, Succeeded, Failed, Crashed + +private[shell] object ObservedStatus: + + /** `processAlive` answers whether the process that wrote `manifest` still + * runs — [[processAlive]] in production. + */ + def of( + manifest: AttemptManifest, + processAlive: AttemptManifest => Boolean + ): ObservedStatus = + manifest.status match + case AttemptStatus.Running => + if processAlive(manifest) then Running else Crashed + case AttemptStatus.Succeeded => Succeeded + case AttemptStatus.Failed => Failed + + /** Whether `manifest.pid` names a live process that started no later than + * `startedAt` (which the attempt takes inside that process) — a later start + * means the pid was reused. The slack absorbs wall-clock steps, which shift + * the start instants the OS reports; a crashed attempt's pid being reused + * within it is negligible. An unknown start instant counts as alive. + */ + def processAlive(manifest: AttemptManifest): Boolean = + ProcessHandle + .of(manifest.pid) + .toScala + .filter(_.isAlive) + .exists: handle => + handle + .info() + .startInstant() + .toScala + .forall(!_.isAfter(manifest.startedAt.plus(ProcessStartSlack))) + + private val ProcessStartSlack = Duration.ofMinutes(1) diff --git a/shell/src/main/scala/orca/shell/sessions/SessionIndex.scala b/shell/src/main/scala/orca/shell/sessions/SessionIndex.scala index 56cfaa44..121f4213 100644 --- a/shell/src/main/scala/orca/shell/sessions/SessionIndex.scala +++ b/shell/src/main/scala/orca/shell/sessions/SessionIndex.scala @@ -166,7 +166,7 @@ private[shell] object SessionIndex: SessionRef(attempt.id, i + 1), attempt.manifest, session, - attempt.crashed + attempt.observedStatus ) val byLineage = all.groupBy(LineageKey.of) val lineages = byLineage.collect: diff --git a/shell/src/main/scala/orca/shell/sessions/SessionNaming.scala b/shell/src/main/scala/orca/shell/sessions/SessionNaming.scala index 6fb61b5d..a9fa0eeb 100644 --- a/shell/src/main/scala/orca/shell/sessions/SessionNaming.scala +++ b/shell/src/main/scala/orca/shell/sessions/SessionNaming.scala @@ -38,3 +38,10 @@ private[shell] object SessionNaming: */ def displayName(session: ManifestSession): String = session.minted.fold(session.agent)(_.name) + + /** ` (crashed)` for a crashed attempt's session; nothing otherwise. */ + def crashedSuffix(status: ObservedStatus): String = status match + case ObservedStatus.Crashed => " (crashed)" + case ObservedStatus.Running | ObservedStatus.Succeeded | + ObservedStatus.Failed => + "" diff --git a/shell/src/main/scala/orca/shell/sessions/SessionPicker.scala b/shell/src/main/scala/orca/shell/sessions/SessionPicker.scala index bf578236..7ca26019 100644 --- a/shell/src/main/scala/orca/shell/sessions/SessionPicker.scala +++ b/shell/src/main/scala/orca/shell/sessions/SessionPicker.scala @@ -151,5 +151,5 @@ private[shell] object SessionPicker: private def rowTail(selection: SessionSelection, marker: String): String = val harness = AgentSpec.harnessNameFor(selection.session.backend) val branch = selection.manifest.branch.fold("")(b => s" on $b") - val crashed = if selection.crashed then " (crashed)" else "" + val crashed = SessionNaming.crashedSuffix(selection.observedStatus) s"[$harness]$marker$branch$crashed" diff --git a/shell/src/main/scala/orca/shell/sessions/SessionSelection.scala b/shell/src/main/scala/orca/shell/sessions/SessionSelection.scala index a78f6577..af24e73b 100644 --- a/shell/src/main/scala/orca/shell/sessions/SessionSelection.scala +++ b/shell/src/main/scala/orca/shell/sessions/SessionSelection.scala @@ -5,10 +5,9 @@ import orca.runner.manifest.{ManifestSession, AttemptManifest} /** One recorded session with the attempt manifest it came from — everything * [[orca.shell.actions.SessionAction.resume]] needs (the harness command comes * from the session; the working directory comes from the manifest, which may - * differ from the shell's own cwd). `crashed` carries the attempt's crashed - * status (status `"Running"` with a dead pid) through to display — resuming - * still offers a crashed attempt's sessions (ADR 0021 §8), but the notice - * should say so. + * differ from the shell's own cwd). `observedStatus` is the attempt's, carried + * through to display — resuming still offers a crashed attempt's sessions (ADR + * 0021 §8), but the notice should say so. * * Lives in `sessions` so [[SessionIndex]] can construct it without `sessions` * depending back on `actions`, which — with `actions/SessionAction` already @@ -18,5 +17,5 @@ private[shell] case class SessionSelection( ref: SessionRef, manifest: AttemptManifest, session: ManifestSession, - crashed: Boolean + observedStatus: ObservedStatus ) diff --git a/shell/src/test/scala/orca/shell/cli/CliTest.scala b/shell/src/test/scala/orca/shell/cli/CliTest.scala index b1ba04ca..3adef4bf 100644 --- a/shell/src/test/scala/orca/shell/cli/CliTest.scala +++ b/shell/src/test/scala/orca/shell/cli/CliTest.scala @@ -3,7 +3,7 @@ package orca.shell.cli import mainargs.{ParserForMethods, TokensReader} import orca.StagePath import orca.agents.{BackendTag, SessionKey} -import orca.runner.manifest.{AttemptStatus, ManifestSession} +import orca.runner.manifest.{AttemptManifest, AttemptStatus, ManifestSession} import orca.settings.{AgentSettings, AgentSpec, SettingsFile, SettingsScope} import orca.shell.{ScanDirs, ShellEnv, TestShellEnv, Tier} import orca.shell.actions.SessionAction @@ -13,6 +13,7 @@ import orca.shell.flows.DiscoveredFlow import orca.shell.run.LaunchResult import orca.shell.sessions.{ ManifestFixtures, + ObservedStatus, RecordedAttempt, SessionIndex, SessionPicker, @@ -42,7 +43,7 @@ class CliTest extends munit.FunSuite: /** `runContinue`'s liveness check: every recorded `Running` attempt reads as * crashed. */ - private val everyPidDead: Long => Boolean = _ => false + private val everyProcessDead: AttemptManifest => Boolean = _ => false private def invoke(args: String*): Either[String, Any] = import Cli.given @@ -757,8 +758,7 @@ class CliTest extends munit.FunSuite: lastActiveAt = "2026-07-18T09:45:00Z" ) ) - ), - crashed = false + ) ), ManifestFixtures.recorded( manifest( @@ -775,8 +775,7 @@ class CliTest extends munit.FunSuite: wireId = None ) ) - ), - crashed = false + ) ) ) @@ -804,8 +803,7 @@ class CliTest extends munit.FunSuite: durable(sessionName = "plan", lastActiveAt = "2026-07-18T09:45:00Z") .copy(minted = None) ) - ), - crashed = false + ) ) ) assertEquals( @@ -935,8 +933,7 @@ class CliTest extends munit.FunSuite: sessions = List( durable(sessionName = sessionName, lastActiveAt = lastActiveAt) ) - ), - crashed = false + ) ) // The older attempt holds the more recently active session. val attempts = List( @@ -966,8 +963,7 @@ class CliTest extends munit.FunSuite: wireId = None ) ) - ), - crashed = false + ) ) ) assertEquals( @@ -986,8 +982,7 @@ class CliTest extends munit.FunSuite: sessions = List( durable(sessionName = "other", lastActiveAt = "2026-07-16T09:30:00Z") ) - ), - crashed = false + ) ) assertEquals( SessionIndex.of(attempts).resolve(Some("older")), @@ -1007,8 +1002,7 @@ class CliTest extends munit.FunSuite: startedAt = "2026-07-18T08:00:00Z", branch = Some("feature/x"), sessions = List(durable(lastActiveAt = lastActiveAt)) - ), - crashed = false + ) ) val attempts = List( onBranch("/repo/a", "2026-07-18T09:30:00Z"), @@ -1056,8 +1050,7 @@ class CliTest extends munit.FunSuite: StagePath.FlowBody.child("Task: wire the parser", 0) ) ) - ), - crashed = false + ) ) ) assertEquals( @@ -1086,8 +1079,7 @@ class CliTest extends munit.FunSuite: durableAgent("agentA", "shared", "2026-07-18T09:30:00Z"), durableAgent("agentB", "shared", "2026-07-18T09:20:00Z") ) - ), - crashed = false + ) ) ) assertEquals( @@ -1116,8 +1108,7 @@ class CliTest extends munit.FunSuite: lastActiveAt = "2026-07-18T09:30:00Z" ) ) - ), - crashed = false + ) ), ManifestFixtures.recorded( manifest( @@ -1129,8 +1120,7 @@ class CliTest extends munit.FunSuite: lastActiveAt = "2026-07-18T08:30:00Z" ) ) - ), - crashed = false + ) ) ) assertEquals( @@ -1160,8 +1150,7 @@ class CliTest extends munit.FunSuite: branch = Some(branch), sessions = List(durable(lastActiveAt = s"2026-07-18T1${i}:00:00Z")) - ), - crashed = false + ) ) assertEquals( SessionPicker @@ -1193,8 +1182,7 @@ class CliTest extends munit.FunSuite: sessionStage = StagePath.FlowBody.child("Task: wire", 0) ) ) - ), - crashed = false + ) ) ) val labels = SessionPicker @@ -1222,8 +1210,7 @@ class CliTest extends munit.FunSuite: sessionStage = StagePath.FlowBody.child("Task: parse", 0) ) ) - ), - crashed = false + ) ) ) val labels = SessionPicker @@ -1244,8 +1231,7 @@ class CliTest extends munit.FunSuite: StagePath.FlowBody.child("Task: wire the parser", 0) ) ) - ), - crashed = false + ) ) ) assertEquals( @@ -1266,8 +1252,7 @@ class CliTest extends munit.FunSuite: lastActiveAt = "2026-07-18T09:30:00Z" ) ) - ), - crashed = false + ) ), ManifestFixtures.recorded( manifest( @@ -1278,8 +1263,7 @@ class CliTest extends munit.FunSuite: lastActiveAt = "2026-07-18T08:30:00Z" ) ) - ), - crashed = false + ) ) ) val labels = SessionPicker @@ -1308,8 +1292,7 @@ class CliTest extends munit.FunSuite: val selection = ManifestFixtures.selection( attempt.manifest, - attempt.manifest.sessions.head, - crashed = false + attempt.manifest.sessions.head ) assertEquals( SessionAction.resumeNotice(selection), @@ -1323,8 +1306,7 @@ class CliTest extends munit.FunSuite: val selection = ManifestFixtures.selection( attempt.manifest, - withStage, - crashed = false + withStage ) assertEquals( SessionAction.resumeNotice(selection), @@ -1337,7 +1319,7 @@ class CliTest extends munit.FunSuite: ManifestFixtures.selection( attempt.manifest, attempt.manifest.sessions.head, - crashed = true + observedStatus = ObservedStatus.Crashed ) assertEquals( SessionAction.resumeNotice(selection), @@ -1381,7 +1363,7 @@ class CliTest extends munit.FunSuite: list = true, json = true, tty = false, - pidAlive = everyPidDead + processAlive = everyProcessDead ), ExitCodes.Ok ) @@ -1430,7 +1412,7 @@ class CliTest extends munit.FunSuite: list = true, json = false, tty = false, - pidAlive = everyPidDead + processAlive = everyProcessDead ), ExitCodes.Ok ) @@ -1449,7 +1431,7 @@ class CliTest extends munit.FunSuite: list = true, json = false, tty = false, - pidAlive = everyPidDead + processAlive = everyProcessDead ), ExitCodes.Ok ) @@ -1468,7 +1450,7 @@ class CliTest extends munit.FunSuite: list = true, json = true, tty = false, - pidAlive = everyPidDead + processAlive = everyProcessDead ), ExitCodes.Ok ) @@ -1492,7 +1474,7 @@ class CliTest extends munit.FunSuite: list = true, json = json, tty = false, - pidAlive = everyPidDead + processAlive = everyProcessDead ), ExitCodes.Ok ) @@ -1529,7 +1511,7 @@ class CliTest extends munit.FunSuite: list = true, json = true, tty = false, - pidAlive = everyPidDead + processAlive = everyProcessDead ), ExitCodes.Ok ) @@ -1537,7 +1519,9 @@ class CliTest extends munit.FunSuite: assert(out.contains("\"kind\":\"Durable\""), out) assert(out.contains("\"kind\":\"Ephemeral\""), out) - test("runContinue --list --json: a crashed attempt reports crashed=true"): + test( + "runContinue --list --json: a crashed attempt reports attemptStatus Crashed" + ): val dir = TempDirs.dir() writeCrashedManifest(dir) val out = captured( @@ -1549,12 +1533,12 @@ class CliTest extends munit.FunSuite: list = true, json = true, tty = false, - pidAlive = everyPidDead + processAlive = everyProcessDead ), ExitCodes.Ok ) ) - assert(out.contains("\"crashed\":true"), out) + assert(out.contains("\"attemptStatus\":\"Crashed\""), out) test( "runContinue --list: a crashed attempt's table row is suffixed (crashed)" @@ -1570,7 +1554,7 @@ class CliTest extends munit.FunSuite: list = true, json = false, tty = false, - pidAlive = everyPidDead + processAlive = everyProcessDead ), ExitCodes.Ok ) @@ -1604,7 +1588,7 @@ class CliTest extends munit.FunSuite: list = false, json = false, tty = true, - pidAlive = everyPidDead + processAlive = everyProcessDead ), ExitCodes.ActionFailed ) diff --git a/shell/src/test/scala/orca/shell/sessions/ManifestFixtures.scala b/shell/src/test/scala/orca/shell/sessions/ManifestFixtures.scala index 0cab8648..d44d1b1a 100644 --- a/shell/src/test/scala/orca/shell/sessions/ManifestFixtures.scala +++ b/shell/src/test/scala/orca/shell/sessions/ManifestFixtures.scala @@ -81,19 +81,32 @@ private[shell] object ManifestFixtures: */ def recorded( manifest: AttemptManifest, - crashed: Boolean = false + observedStatus: ObservedStatus ): RecordedAttempt = RecordedAttempt( AttemptId(manifest.startedAt, manifest.pid), manifest, - crashed + observedStatus ) + /** [[recorded]] with the writing process still alive. */ + def recorded(manifest: AttemptManifest): RecordedAttempt = + recorded(manifest, ObservedStatus.of(manifest, _ => true)) + + /** `session`, recorded in `manifest`, as a [[SessionIndex]] holds it, with + * the writing process still alive. + */ + def selection( + manifest: AttemptManifest, + session: ManifestSession + ): SessionSelection = + selection(manifest, session, ObservedStatus.of(manifest, _ => true)) + /** `session`, recorded in `manifest`, as a [[SessionIndex]] holds it. */ def selection( manifest: AttemptManifest, session: ManifestSession, - crashed: Boolean = false + observedStatus: ObservedStatus ): SessionSelection = SessionSelection( SessionRef( @@ -102,7 +115,7 @@ private[shell] object ManifestFixtures: ), manifest, session, - crashed + observedStatus ) /** Writes `manifest` where `ManifestReader` lists `dir`'s attempts, under the diff --git a/shell/src/test/scala/orca/shell/sessions/ManifestReaderTest.scala b/shell/src/test/scala/orca/shell/sessions/ManifestReaderTest.scala index aa8cf7a4..ba18e485 100644 --- a/shell/src/test/scala/orca/shell/sessions/ManifestReaderTest.scala +++ b/shell/src/test/scala/orca/shell/sessions/ManifestReaderTest.scala @@ -1,14 +1,15 @@ package orca.shell.sessions import orca.{AttemptId, OrcaDir, OrcaFlowException} +import orca.runner.manifest.AttemptManifest import orca.testkit.TempDirs import java.time.Instant class ManifestReaderTest extends munit.FunSuite: - private val alwaysDead: Long => Boolean = _ => false - private val alwaysAlive: Long => Boolean = _ => true + private val alwaysDead: AttemptManifest => Boolean = _ => false + private val alwaysAlive: AttemptManifest => Boolean = _ => true private def attemptsDir(workDir: os.Path): os.Path = workDir / ".orca" / "cache" / "attempts" @@ -190,9 +191,9 @@ class ManifestReaderTest extends munit.FunSuite: val AttemptListing(attempts, warnings) = ManifestReader.list(workDir, Nil, alwaysDead) assertEquals(warnings, Nil) - assertEquals(attempts.map(_.crashed), List(true)) + assertEquals(attempts.map(_.observedStatus), List(ObservedStatus.Crashed)) - test("a running manifest with a live pid is included and not marked crashed"): + test("a running manifest with a live pid is included and running"): val workDir = TempDirs.dir() writeManifest( workDir, @@ -203,7 +204,7 @@ class ManifestReaderTest extends munit.FunSuite: val AttemptListing(attempts, warnings) = ManifestReader.list(workDir, Nil, alwaysAlive) assertEquals(warnings, Nil) - assertEquals(attempts.map(_.crashed), List(false)) + assertEquals(attempts.map(_.observedStatus), List(ObservedStatus.Running)) test( "list skips a manifest with an unrecognised outcome, warning by filename" @@ -241,17 +242,17 @@ class ManifestReaderTest extends munit.FunSuite: warnings.head ) - test("a finished manifest is never marked crashed, even with a dead pid"): + test("a failed manifest with a dead pid is not crashed"): val workDir = TempDirs.dir() writeManifest( workDir, startedAt = "2026-07-18T10:00:00Z", pid = 999999, - status = "Succeeded" + status = "Failed" ) val AttemptListing(attempts, _) = ManifestReader.list(workDir, Nil, alwaysDead) - assertEquals(attempts.map(_.crashed), List(false)) + assertEquals(attempts.map(_.observedStatus), List(ObservedStatus.Failed)) test( "list skips a manifest whose minted key has no stage, warning by filename" diff --git a/shell/src/test/scala/orca/shell/sessions/ManifestRoundTripTest.scala b/shell/src/test/scala/orca/shell/sessions/ManifestRoundTripTest.scala index 25ae9784..799c78ac 100644 --- a/shell/src/test/scala/orca/shell/sessions/ManifestRoundTripTest.scala +++ b/shell/src/test/scala/orca/shell/sessions/ManifestRoundTripTest.scala @@ -48,10 +48,10 @@ class ManifestRoundTripTest extends munit.FunSuite: writer.finish(AttemptOutcome.Succeeded) val AttemptListing(attempts, warnings) = - ManifestReader.list(workDir, Nil, pidAlive = _ => true) + ManifestReader.list(workDir, Nil, processAlive = _ => true) assertEquals(warnings, Nil) assertEquals(attempts.size, 1) - assertEquals(attempts.head.crashed, false) + assertEquals(attempts.head.observedStatus, ObservedStatus.Succeeded) val session = attempts.head.manifest.sessions.head assertEquals(session.backend, BackendTag.ClaudeCode) assertEquals(session.wireId, Some("wire-1")) diff --git a/shell/src/test/scala/orca/shell/sessions/ObservedStatusTest.scala b/shell/src/test/scala/orca/shell/sessions/ObservedStatusTest.scala new file mode 100644 index 00000000..d2bfd24b --- /dev/null +++ b/shell/src/test/scala/orca/shell/sessions/ObservedStatusTest.scala @@ -0,0 +1,30 @@ +package orca.shell.sessions + +import java.time.Instant + +class ObservedStatusTest extends munit.FunSuite: + + private def attempt(pid: Long, startedAt: Instant) = + ManifestFixtures.manifest( + pid = pid, + startedAt = startedAt.toString, + sessions = Nil + ) + + test( + "processAlive: a process started within the slack after the attempt is alive" + ): + val self = ProcessHandle.current() + val startedAt = self.info().startInstant().get().minusSeconds(30) + assert(ObservedStatus.processAlive(attempt(self.pid(), startedAt))) + + test("processAlive: a live process started after the attempt reused its pid"): + val self = ProcessHandle.current() + assert(!ObservedStatus.processAlive(attempt(self.pid(), Instant.EPOCH))) + + test("processAlive: an exited process is not alive"): + val exited = os.proc("true").spawn() + assertEquals(exited.wrapped.waitFor(), 0) + assert( + !ObservedStatus.processAlive(attempt(exited.wrapped.pid(), Instant.now())) + ) diff --git a/shell/src/test/scala/orca/shell/sessions/SessionPickerTest.scala b/shell/src/test/scala/orca/shell/sessions/SessionPickerTest.scala index 6d3e61ad..3abdb68f 100644 --- a/shell/src/test/scala/orca/shell/sessions/SessionPickerTest.scala +++ b/shell/src/test/scala/orca/shell/sessions/SessionPickerTest.scala @@ -30,8 +30,7 @@ class SessionPickerTest extends munit.FunSuite: lastActiveAt = "2026-07-16T09:01:00Z" ) ) - ), - crashed = false + ) ) val attempt2 = ManifestFixtures.recorded( manifest( @@ -54,8 +53,7 @@ class SessionPickerTest extends munit.FunSuite: lastActiveAt = "2026-07-17T09:21:00Z" ) ) - ), - crashed = false + ) ) val attempt3 = ManifestFixtures.recorded( manifest( @@ -72,8 +70,7 @@ class SessionPickerTest extends munit.FunSuite: lastActiveAt = "2026-07-18T09:40:00Z" ) ) - ), - crashed = false + ) ) List( attempt3, @@ -163,8 +160,7 @@ class SessionPickerTest extends munit.FunSuite: sessions = List( durable(stage = Some("Plan"), lastActiveAt = "2026-07-17T09:05:00Z") ) - ), - crashed = false + ) ) val attempt2 = ManifestFixtures.recorded( manifest( @@ -172,8 +168,7 @@ class SessionPickerTest extends munit.FunSuite: sessions = List( durable(stage = Some("Task"), lastActiveAt = "2026-07-18T09:05:00Z") ) - ), - crashed = false + ) ) val rows = SessionPicker.sessionRows( @@ -200,8 +195,7 @@ class SessionPickerTest extends munit.FunSuite: stage = Some("Task: add auth") ) ) - ), - crashed = false + ) ) assertEquals( SessionPicker @@ -216,8 +210,7 @@ class SessionPickerTest extends munit.FunSuite: "sessionRows omits the earlier-occurrences expander when there's only one occurrence" ): val run = ManifestFixtures.recorded( - manifest(sessions = List(durable())), - crashed = false + manifest(sessions = List(durable())) ) assertEquals( SessionPicker @@ -228,8 +221,7 @@ class SessionPickerTest extends munit.FunSuite: test("sessionRows singularises a count of 1 in the expander label"): val run = ManifestFixtures.recorded( - manifest(sessions = List(durable(), ephemeral())), - crashed = false + manifest(sessions = List(durable(), ephemeral())) ) assertEquals( SessionPicker @@ -262,8 +254,7 @@ class SessionPickerTest extends munit.FunSuite: lastActiveAt = "2026-07-18T09:05:00Z" ) ) - ), - crashed = false + ) ) assertEquals( SessionPicker @@ -292,8 +283,7 @@ class SessionPickerTest extends munit.FunSuite: lastActiveAt = "2026-07-18T09:05:00Z" ) ) - ), - crashed = false + ) ) val rows = SessionPicker.sessionRows(SessionIndex.of(List(run)), expanded = false) @@ -311,8 +301,7 @@ class SessionPickerTest extends munit.FunSuite: "feat-b" -> "2026-07-18T09:00:00Z" ).map: (b, at) => ManifestFixtures.recorded( - manifest(branch = Some(b), sessions = List(durable(lastActiveAt = at))), - crashed = false + manifest(branch = Some(b), sessions = List(durable(lastActiveAt = at))) ) assertEquals( SessionPicker @@ -331,8 +320,7 @@ class SessionPickerTest extends munit.FunSuite: manifest( branch = Some("feat-a"), sessions = List(durable(lastActiveAt = at)) - ), - crashed = false + ) ) assertEquals( SessionPicker @@ -361,8 +349,7 @@ class SessionPickerTest extends munit.FunSuite: startedAt = startedAt, branch = Some("feat-a"), sessions = sessions - ), - crashed = false + ) ) assertEquals( SessionPicker @@ -379,7 +366,7 @@ class SessionPickerTest extends munit.FunSuite: val run = ManifestFixtures.recorded( manifest(sessions = List(durable()), branch = Some("feat-a")), - crashed = true + observedStatus = ObservedStatus.Crashed ) assertEquals( SessionPicker @@ -390,10 +377,7 @@ class SessionPickerTest extends munit.FunSuite: test("sessionRows disables a wireId-less session, naming its harness"): val run = ManifestFixtures.recorded( - manifest(sessions = - List(durable(backend = BackendTag.Pi, wireId = None)) - ), - crashed = false + manifest(sessions = List(durable(backend = BackendTag.Pi, wireId = None))) ) assertEquals( SessionPicker @@ -405,8 +389,7 @@ class SessionPickerTest extends munit.FunSuite: test("sessionRows enables a claude session with a wireId"): val run = ManifestFixtures.recorded( - manifest(sessions = List(durable())), - crashed = false + manifest(sessions = List(durable())) ) assertEquals( SessionPicker