diff --git a/crates/console/prometeu-firmware/src/firmware/firmware.rs b/crates/console/prometeu-firmware/src/firmware/firmware.rs index 3bb60111..b60811ed 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware.rs @@ -175,10 +175,13 @@ mod tests { use prometeu_bytecode::model::{BytecodeModule, FunctionMeta, SyscallDecl}; use prometeu_drivers::hardware::Hardware; use prometeu_hal::cartridge::{AppMode, AssetsPayloadSource}; + use prometeu_hal::color::Color; + use prometeu_hal::primitives::Rect; use prometeu_hal::syscalls::caps; use prometeu_system::CrashReport; use prometeu_system::process::ProcessState; - use prometeu_system::task::TaskState; + use prometeu_system::task::{TaskId, TaskState}; + use prometeu_system::windows::WindowOwner; fn halting_program() -> Vec { let code = assemble("HALT").expect("assemble"); @@ -262,6 +265,34 @@ mod tests { } } + fn load_shell_running_firmware() -> (Firmware, Hardware, InputSignals, TaskId) { + let mut firmware = Firmware::new(None); + let mut hardware = Hardware::new(); + let signals = InputSignals::default(); + + firmware.load_cartridge(valid_cartridge(AppMode::Shell)); + firmware.tick(&signals, &mut hardware); + + let task_id = match &firmware.state { + FirmwareState::ShellRunning(step) => step.task_id, + other => panic!("expected ShellRunning state, got {:?}", other), + }; + + (firmware, hardware, signals, task_id) + } + + fn focus_window(firmware: &mut Firmware, owner: WindowOwner) { + let mut windows = firmware.os.windows(); + windows.remove_all_windows(); + let id = windows.add_window( + "Test Window".to_string(), + owner, + Rect { x: 0, y: 0, w: 10, h: 10 }, + Color::WHITE, + ); + windows.set_focus(id); + } + #[test] fn load_cartridge_transitions_to_app_crashes_when_vm_init_fails() { let mut firmware = Firmware::new(None); @@ -362,14 +393,79 @@ mod tests { firmware.load_cartridge(valid_cartridge(AppMode::Shell)); firmware.tick(&signals, &mut hardware); + let task_id = match &firmware.state { + FirmwareState::ShellRunning(step) => step.task_id, + other => panic!("expected ShellRunning state, got {:?}", other), + }; let tick_index_before_system_update = firmware.os.vm().tick_index(); firmware.tick(&signals, &mut hardware); assert!(matches!(firmware.state, FirmwareState::ShellRunning(_))); + assert!(firmware.os.windows().focused_window_belongs_to_task(task_id)); assert_eq!(firmware.os.vm().tick_index(), tick_index_before_system_update + 1); } + #[test] + fn shell_running_closes_task_and_returns_hub_home_without_focused_window() { + let (mut firmware, mut hardware, signals, task_id) = load_shell_running_firmware(); + + firmware.os.windows().remove_all_windows(); + firmware.tick(&signals, &mut hardware); + + assert!(matches!(firmware.state, FirmwareState::HubHome(_))); + assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed)); + assert_eq!( + firmware.os.lifecycle().process_state_for_task(task_id), + Ok(ProcessState::Stopped) + ); + } + + #[test] + fn shell_running_closes_task_and_returns_hub_home_for_focused_hub_window() { + let (mut firmware, mut hardware, signals, task_id) = load_shell_running_firmware(); + + focus_window(&mut firmware, WindowOwner::Hub); + firmware.tick(&signals, &mut hardware); + + assert!(matches!(firmware.state, FirmwareState::HubHome(_))); + assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed)); + assert_eq!( + firmware.os.lifecycle().process_state_for_task(task_id), + Ok(ProcessState::Stopped) + ); + } + + #[test] + fn shell_running_closes_task_and_returns_hub_home_for_focused_overlay_window() { + let (mut firmware, mut hardware, signals, task_id) = load_shell_running_firmware(); + + focus_window(&mut firmware, WindowOwner::Overlay); + firmware.tick(&signals, &mut hardware); + + assert!(matches!(firmware.state, FirmwareState::HubHome(_))); + assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed)); + assert_eq!( + firmware.os.lifecycle().process_state_for_task(task_id), + Ok(ProcessState::Stopped) + ); + } + + #[test] + fn shell_running_closes_task_and_returns_hub_home_for_other_task_window() { + let (mut firmware, mut hardware, signals, task_id) = load_shell_running_firmware(); + + focus_window(&mut firmware, WindowOwner::Task(TaskId(task_id.0 + 1))); + firmware.tick(&signals, &mut hardware); + + assert!(matches!(firmware.state, FirmwareState::HubHome(_))); + assert_eq!(firmware.os.lifecycle().task_state(task_id), Some(TaskState::Closed)); + assert_eq!( + firmware.os.lifecycle().process_state_for_task(task_id), + Ok(ProcessState::Stopped) + ); + } + #[test] fn load_cartridge_routes_game_apps_to_game_running() { let mut firmware = Firmware::new(None); @@ -380,5 +476,6 @@ mod tests { firmware.tick(&signals, &mut hardware); assert!(matches!(firmware.state, FirmwareState::GameRunning(_))); + assert_eq!(firmware.os.windows().window_count(), 0); } } diff --git a/crates/console/prometeu-firmware/src/firmware/firmware_step_shell_running.rs b/crates/console/prometeu-firmware/src/firmware/firmware_step_shell_running.rs index 94339e96..9a150073 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware_step_shell_running.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware_step_shell_running.rs @@ -66,10 +66,13 @@ impl ShellRunningStep { return Some(FirmwareState::AppCrashes(AppCrashesStep { report })); } - if !outcome.focused_window_active { + if !ctx.os.windows().focused_window_belongs_to_task(self.task_id) { ctx.os.info( LogSource::Hub, - format!("Closing shell task {:?}: no focused window active", self.task_id), + format!( + "Closing shell task {:?}: focused window does not belong to task", + self.task_id, + ), ); if let Err(error) = ctx.os.lifecycle().close_task(self.task_id) { ctx.os.error( diff --git a/crates/console/prometeu-system/src/os/facades/window.rs b/crates/console/prometeu-system/src/os/facades/window.rs index 5e6c8c90..db08e9a3 100644 --- a/crates/console/prometeu-system/src/os/facades/window.rs +++ b/crates/console/prometeu-system/src/os/facades/window.rs @@ -1,4 +1,5 @@ use crate::os::SystemOS; +use crate::task::TaskId; use crate::windows::{Window, WindowId, WindowOwner}; use prometeu_hal::color::Color; use prometeu_hal::primitives::Rect; @@ -26,6 +27,10 @@ impl<'a> WindowFacade<'a> { self.os.window_manager.focused } + pub fn focused_window_belongs_to_task(&self, task_id: TaskId) -> bool { + self.os.window_manager.focused_window_belongs_to_task(task_id) + } + pub fn close_window(&mut self, id: WindowId) { self.os.window_manager.remove_window(id); } diff --git a/crates/console/prometeu-system/src/programs/prometeu_hub/prometeu_hub.rs b/crates/console/prometeu-system/src/programs/prometeu_hub/prometeu_hub.rs index 8c219df7..2828113f 100644 --- a/crates/console/prometeu-system/src/programs/prometeu_hub/prometeu_hub.rs +++ b/crates/console/prometeu-system/src/programs/prometeu_hub/prometeu_hub.rs @@ -11,7 +11,6 @@ pub struct PrometeuHub; pub struct SystemProfileUpdate { pub crash: Option, - pub focused_window_active: bool, } impl Default for PrometeuHub { @@ -103,9 +102,6 @@ impl PrometeuHub { self.render(os, hw); hw.gfx_mut().present(); - SystemProfileUpdate { - crash, - focused_window_active: os.windows().focused_window().is_some(), - } + SystemProfileUpdate { crash } } } diff --git a/crates/console/prometeu-system/src/services/windows/window_manager.rs b/crates/console/prometeu-system/src/services/windows/window_manager.rs index 51099ecd..70e475fe 100644 --- a/crates/console/prometeu-system/src/services/windows/window_manager.rs +++ b/crates/console/prometeu-system/src/services/windows/window_manager.rs @@ -1,4 +1,5 @@ use crate::services::windows::window::{Window, WindowId}; +use crate::task::TaskId; use crate::windows::WindowOwner; use prometeu_hal::color::Color; use prometeu_hal::primitives::Rect; @@ -51,6 +52,17 @@ impl WindowManager { window.has_focus = window.id == id; } } + + pub fn focused_window_belongs_to_task(&self, task_id: TaskId) -> bool { + let Some(focused_id) = self.focused else { + return false; + }; + + self.windows + .iter() + .find(|window| window.id == focused_id) + .is_some_and(|window| window.owner == WindowOwner::Task(task_id)) + } } #[cfg(test)] @@ -103,4 +115,91 @@ mod tests { assert_eq!(wm.windows.len(), 0); assert_eq!(wm.focused, None); } + + #[test] + fn focused_window_belongs_to_task_returns_false_without_focus() { + let mut wm = WindowManager::new(); + wm.add_window( + "Task Window".to_string(), + WindowOwner::Task(crate::task::TaskId(1)), + Rect { x: 0, y: 0, w: 10, h: 10 }, + Color::WHITE, + ); + + assert!(!wm.focused_window_belongs_to_task(crate::task::TaskId(1))); + } + + #[test] + fn focused_window_belongs_to_task_matches_focused_task_owner() { + let mut wm = WindowManager::new(); + let id = wm.add_window( + "Task Window".to_string(), + WindowOwner::Task(crate::task::TaskId(1)), + Rect { x: 0, y: 0, w: 10, h: 10 }, + Color::WHITE, + ); + + wm.set_focus(id); + + assert!(wm.focused_window_belongs_to_task(crate::task::TaskId(1))); + } + + #[test] + fn focused_window_belongs_to_task_rejects_other_task_owner() { + let mut wm = WindowManager::new(); + let id = wm.add_window( + "Task Window".to_string(), + WindowOwner::Task(crate::task::TaskId(2)), + Rect { x: 0, y: 0, w: 10, h: 10 }, + Color::WHITE, + ); + + wm.set_focus(id); + + assert!(!wm.focused_window_belongs_to_task(crate::task::TaskId(1))); + } + + #[test] + fn focused_window_belongs_to_task_rejects_hub_owner() { + let mut wm = WindowManager::new(); + let id = wm.add_window( + "Hub Window".to_string(), + WindowOwner::Hub, + Rect { x: 0, y: 0, w: 10, h: 10 }, + Color::WHITE, + ); + + wm.set_focus(id); + + assert!(!wm.focused_window_belongs_to_task(crate::task::TaskId(1))); + } + + #[test] + fn focused_window_belongs_to_task_rejects_overlay_owner() { + let mut wm = WindowManager::new(); + let id = wm.add_window( + "Overlay Window".to_string(), + WindowOwner::Overlay, + Rect { x: 0, y: 0, w: 10, h: 10 }, + Color::WHITE, + ); + + wm.set_focus(id); + + assert!(!wm.focused_window_belongs_to_task(crate::task::TaskId(1))); + } + + #[test] + fn focused_window_belongs_to_task_rejects_stale_focus() { + let mut wm = WindowManager::new(); + wm.add_window( + "Task Window".to_string(), + WindowOwner::Task(crate::task::TaskId(1)), + Rect { x: 0, y: 0, w: 10, h: 10 }, + Color::WHITE, + ); + wm.focused = Some(WindowId(999)); + + assert!(!wm.focused_window_belongs_to_task(crate::task::TaskId(1))); + } } diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 419f40a8..ba91153e 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,4 +1,5 @@ -{"type":"meta","next_id":{"DSC":35,"AGD":35,"DEC":27,"PLN":59,"LSN":44,"CLSN":1}} +{"type":"meta","next_id":{"DSC":36,"AGD":36,"DEC":28,"PLN":62,"LSN":45,"CLSN":1}} +{"type":"discussion","id":"DSC-0035","status":"done","ticket":"task-owned-shell-windows","title":"Agenda - Task-Owned Shell Windows","created_at":"2026-05-15","updated_at":"2026-05-15","tags":["runtime","os","task","window-manager","shell","lifecycle"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0044","file":"discussion/lessons/DSC-0035-task-owned-shell-windows/LSN-0044-task-window-liveness-belongs-to-the-task.md","status":"done","created_at":"2026-05-15","updated_at":"2026-05-15"}]} {"type":"discussion","id":"DSC-0034","status":"done","ticket":"system-os-domain-facades","title":"Agenda - SystemOS Domain Facades","created_at":"2026-05-15","updated_at":"2026-05-15","tags":["runtime","os","services","api-surface","lifecycle","fs"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0043","file":"discussion/lessons/DSC-0034-system-os-domain-facades/LSN-0043-systemos-domain-facades.md","status":"done","created_at":"2026-05-15","updated_at":"2026-05-15"}]} {"type":"discussion","id":"DSC-0023","status":"done","ticket":"perf-full-migration-to-atomic-telemetry","title":"Agenda - [PERF] Full Migration to Atomic Telemetry","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["perf","runtime","telemetry"],"agendas":[{"id":"AGD-0021","file":"AGD-0021-full-migration-to-atomic-telemetry.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"decisions":[{"id":"DEC-0008","file":"DEC-0008-full-migration-to-atomic-telemetry.md","status":"accepted","created_at":"2026-04-10","updated_at":"2026-04-10"}],"plans":[{"id":"PLN-0007","file":"PLN-0007-full-migration-to-atomic-telemetry.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}],"lessons":[{"id":"LSN-0028","file":"discussion/lessons/DSC-0023-perf-full-migration-to-atomic-telemetry/LSN-0028-converging-to-single-atomic-telemetry-source.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]} {"type":"discussion","id":"DSC-0020","status":"done","ticket":"jenkins-gitea-integration","title":"Jenkins Gitea Integration and Relocation","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["ci","jenkins","gitea"],"agendas":[{"id":"AGD-0018","file":"AGD-0018-jenkins-gitea-integration-and-relocation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"decisions":[{"id":"DEC-0003","file":"DEC-0003-jenkins-gitea-strategy.md","status":"accepted","created_at":"2026-04-07","updated_at":"2026-04-07"}],"plans":[{"id":"PLN-0003","file":"PLN-0003-jenkins-gitea-execution.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}],"lessons":[{"id":"LSN-0021","file":"discussion/lessons/DSC-0020-jenkins-gitea-integration/LSN-0021-jenkins-gitea-integration.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]} diff --git a/discussion/lessons/DSC-0035-task-owned-shell-windows/LSN-0044-task-window-liveness-belongs-to-the-task.md b/discussion/lessons/DSC-0035-task-owned-shell-windows/LSN-0044-task-window-liveness-belongs-to-the-task.md new file mode 100644 index 00000000..c6dadf5d --- /dev/null +++ b/discussion/lessons/DSC-0035-task-owned-shell-windows/LSN-0044-task-window-liveness-belongs-to-the-task.md @@ -0,0 +1,112 @@ +--- +id: LSN-0044 +ticket: task-owned-shell-windows +title: Task Window Liveness Belongs to the Task +created: 2026-05-15 +tags: [runtime, os, task, window-manager, shell, lifecycle] +--- + +# Task Window Liveness Belongs to the Task + +## Context + +The Prometeu OS model separates execution, navigation, and visual presence: + +```text +Process = technical execution +Task = navigable presence +Window = visual presence of the task +``` + +Before `DSC-0035`, shell execution used a generic `focused_window_active` +signal. That signal only answered whether any window was focused. It did not +prove that the focused window represented the shell task currently driven by +`ShellRunningStep`. + +The implemented contract replaced that generic signal with a task-owned window +predicate: + +```text +os.windows().focused_window_belongs_to_task(task_id) +``` + +## Key Decisions + +### Shell liveness requires the task's own focused window + +**What:** `ShellRunningStep` continues only when the task is foreground and the +global focused window is owned by `WindowOwner::Task(task_id)`. + +**Why:** A shell task in foreground must have its own visual presence. Any +focused window is not enough; the focused window must belong to the same task. + +**Trade-offs:** This v1 rule is strict. It does not preserve a shell task under +focused overlays, minimized windows, background execution, or app switcher +state. Those behaviors need their own explicit contracts. + +### Window ownership is queried through the window facade + +**What:** `WindowFacade` exposes +`focused_window_belongs_to_task(task_id)`. + +**Why:** The question is about the global focused window and its owner. It is a +window-domain query, not a lifecycle transition. + +**Trade-offs:** Firmware still coordinates across domains: it asks the window +facade for ownership and then calls lifecycle when the predicate is false. + +### Lifecycle still owns state transitions + +**What:** When the shell task loses its eligible focused window, +`ShellRunningStep` closes the task through `os.lifecycle().close_task(task_id)` +before returning to `HubHome`. + +**Why:** Returning to Hub without lifecycle closure would leave task and process +state dependent on implicit cleanup. + +**Trade-offs:** A visual close becomes a lifecycle event for foreground shell +apps. That is correct for v1 shell apps, but it must not be generalized to +background services without a separate decision. + +## Patterns and Algorithms + +Use this split when OS behavior crosses services: + +```text +WindowFacade + answers questions about windows, focus, and ownership + +LifecycleFacade + changes TaskState and ProcessState + +Firmware step + coordinates policy between the two domains +``` + +The useful pattern is not "windows close tasks". The pattern is: + +1. derive a visual-presence predicate from the window domain; +2. let the firmware step decide what the predicate means in that state; +3. perform lifecycle transitions through `SystemOS` lifecycle. + +## Pitfalls + +- Do not use a generic "some window is focused" signal as a task liveness + contract. +- Do not treat `Hub` or `Overlay` windows as shell task windows. +- Do not model game cartridges as `WindowManager` windows. Games remain + fullscreen `GameRunningStep` sessions. +- Do not add overlay preservation, minimization, app switcher, or background + service behavior as incidental side effects of shell liveness. +- Do not return to Hub without closing the shell task through lifecycle. + +## Takeaways + +- Task-owned shell windows bind shell liveness to the task's own visual + presence. +- Ownership predicates belong in the window facade; state mutation belongs in + lifecycle. +- Strict v1 semantics are easier to evolve than an implicit "active window" + concept. +- Future overlay or background behavior should be introduced by explicit + policy, not by weakening the shell liveness predicate. diff --git a/docs/specs/runtime/12-firmware-pos-and-prometeuhub.md b/docs/specs/runtime/12-firmware-pos-and-prometeuhub.md index 93a832bd..a4b90b86 100644 --- a/docs/specs/runtime/12-firmware-pos-and-prometeuhub.md +++ b/docs/specs/runtime/12-firmware-pos-and-prometeuhub.md @@ -31,6 +31,14 @@ The VM does not own the machine lifecycle. Firmware does. - **AppMode**: cartridge mode, currently `Game` or `System`. - **Logical frame**: execution unit completed when the app reaches `FRAME_SYNC`. - **Host tick**: the host-driven outer update tick. +- **Process**: technical execution owned by POS lifecycle. +- **Task**: navigable presence associated with an app flow. +- **Window**: visual presence for a shell/system task when that task is + represented by the system window model. +- **WindowOwner**: ownership marker for a system window. The v1 owners are + `Hub`, `Task(TaskId)`, and `Overlay`. +- **Shell task window**: a `WindowManager` window whose owner is + `WindowOwner::Task(TaskId)`. ## 3 POS Responsibilities @@ -69,6 +77,8 @@ Game-mode cartridges: - transition firmware into the game-running state; - run as the active app flow; - present through the main frame path. +- run as fullscreen firmware sessions; +- are not `WindowManager` windows. ### System @@ -77,8 +87,59 @@ System-mode cartridges: - are initialized by POS; - are integrated into the Hub/window environment; - do not replace the firmware state with the game-running path. +- are represented as shell task windows when they expose a visual shell + presence. -## 6 Cartridge Load Flow +## 6 Task-Owned Shell Windows + +The shell window contract separates execution, navigation, and visual +presence: + +```text +Process = technical execution +Task = navigable presence +Window = visual presence of the task +``` + +For shell/system apps managed by the window model, the visible shell presence +must be a `WindowManager` window owned by `WindowOwner::Task(TaskId)`. + +`Hub` windows are not shell task windows. `Overlay` windows are not shell task +windows in this v1 contract. Overlay/modal policy, minimization, app switching, +dock behavior, background services, and complex multi-window semantics are +future contracts and are not defined here. + +Game cartridges must not be represented as `WindowManager` windows. They remain +fullscreen sessions driven by `GameRunningStep`. + +For v1, `ShellRunningStep` continues a shell task only while both conditions are +true: + +```text +TaskState::Foreground ++ +focused window owner == WindowOwner::Task(task_id) +``` + +The runtime query for the focused-window ownership predicate is: + +```text +os.windows().focused_window_belongs_to_task(task_id) +``` + +If a foreground shell task loses its eligible focused window, +`ShellRunningStep` must close the task through `SystemOS` lifecycle before +returning to Hub: + +```text +Shell task loses its eligible focused window + -> os.lifecycle().close_task(task_id) + -> TaskState::Closed + -> ProcessState::Stopped + -> FirmwareState::HubHome +``` + +## 7 Cartridge Load Flow Current high-level flow: @@ -93,7 +154,7 @@ If VM initialization fails, firmware transitions to the crash path. POS selects the cartridge and its execution context, but the cartridge's initial callable is not chosen by firmware metadata. Execution starts from the cartridge boot protocol defined in [`13-cartridge.md`](13-cartridge.md), currently `func_id = 0`. -## 7 Firmware States +## 8 Firmware States The current firmware state model includes: @@ -107,7 +168,7 @@ The current firmware state model includes: These states express machine orchestration above the VM. They are not guest-visible bytecode states. -## 8 Execution Contract +## 9 Execution Contract For game-mode execution, firmware/runtime coordination preserves: @@ -118,7 +179,7 @@ For game-mode execution, firmware/runtime coordination preserves: This keeps the machine model deterministic and observable. -## 9 Crash Handling +## 10 Crash Handling Firmware owns terminal fault presentation. @@ -130,7 +191,7 @@ When a terminal app fault occurs: Crash handling is outside the guest VM execution model. -## 10 Relationship to Other Specs +## 11 Relationship to Other Specs - [`02-vm-instruction-set.md`](02-vm-instruction-set.md) defines the VM subsystem run by firmware. - [`09-events-and-concurrency.md`](09-events-and-concurrency.md) defines the frame-boundary model used by firmware.