dev/task-owned-shell-windows #29

Merged
bquarkz merged 5 commits from dev/task-owned-shell-windows into master 2026-05-15 15:13:56 +00:00
8 changed files with 388 additions and 14 deletions

View File

@ -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<u8> {
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);
}
}

View File

@ -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(

View File

@ -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);
}

View File

@ -11,7 +11,6 @@ pub struct PrometeuHub;
pub struct SystemProfileUpdate {
pub crash: Option<CrashReport>,
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 }
}
}

View File

@ -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)));
}
}

View File

@ -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"}]}

View File

@ -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.

View File

@ -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.