diff --git a/.yoi/tickets/00001KX7NS2PD/item.md b/.yoi/tickets/00001KX7NS2PD/item.md index 2ee4b9a2..6cfbb7f3 100644 --- a/.yoi/tickets/00001KX7NS2PD/item.md +++ b/.yoi/tickets/00001KX7NS2PD/item.md @@ -1,8 +1,8 @@ --- title: 'Remove durable worker lifecycle_state' -state: 'inprogress' +state: 'done' created_at: '2026-07-11T04:08:12Z' -updated_at: '2026-07-11T04:09:06Z' +updated_at: '2026-07-11T04:33:35Z' assignee: null queued_by: 'yoi ticket' queued_at: '2026-07-11T04:09:06Z' diff --git a/.yoi/tickets/00001KX7NS2PD/thread.md b/.yoi/tickets/00001KX7NS2PD/thread.md index f7e234b1..56fd03e9 100644 --- a/.yoi/tickets/00001KX7NS2PD/thread.md +++ b/.yoi/tickets/00001KX7NS2PD/thread.md @@ -39,4 +39,38 @@ Ticket を `yoi ticket` が queued にしました。 State changed to `inprogress`. +--- + + + +## Implementation report + +Removed durable worker lifecycle state from the Backend worker registry. + +- `WorkerRegistryRecord` no longer has `lifecycle_state`. +- Active worker_registry CREATE/INSERT/SELECT/read paths no longer include lifecycle state. +- Added schema migration v4 to rebuild legacy worker_registry tables without the old lifecycle column. +- Registry-only Worker projection now reports `archived` instead of stale `idle`/`running`/`stopped` values. +- Live Worker merge no longer lets registry data overwrite Runtime status/state. +- Cleanup planning uses live Runtime workers for running-linked detection; registry records only provide retention/link metadata. +- Removed lifecycle_state from frontend cleanup candidate types/display. + +Validation: +- cargo test -q -p yoi-workspace-server +- cd web/workspace && deno task check +- cd web/workspace && deno task test +- cargo test -q +- git diff --check +- nix build .#yoi --no-link + + +--- + + + +## State changed + +State changed to `done`. + + --- diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 732300c9..dc9899de 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -601,7 +601,6 @@ pub struct CleanupWorkerCandidate { pub blocking_reason: Option, pub pinned: bool, pub retention_state: String, - pub lifecycle_state: String, pub linked_workdir_ids: Vec, pub running_linked: bool, pub estimated_reclaim_bytes: Option, @@ -1579,7 +1578,13 @@ fn build_runtime_cleanup_plan( api: &WorkspaceApi, runtime_id: &str, ) -> ApiResult { - let _ = workers_response(api.clone()); + let workers = workers_response(api.clone())?; + let live_running_worker_ids: HashSet = workers + .items + .iter() + .filter(|worker| worker.status == "running" || worker.state == "running") + .map(|worker| backend_worker_id(worker.runtime_id.as_str(), worker.worker_id.as_str())) + .collect(); let (workdir_summaries, mut diagnostics) = match runtime_working_directory_summaries(api, runtime_id) { Ok(result) => result, @@ -1618,7 +1623,7 @@ fn build_runtime_cleanup_plan( let links = api .store .list_worker_workdir_links(&api.config.workspace_id, record.worker_id.as_str())?; - let is_running = record.lifecycle_state == "running"; + let is_running = live_running_worker_ids.contains(&record.worker_id); let pinned = record.retention_state == "pinned"; let blocking_reason = if pinned { Some("worker is pinned".to_string()) @@ -1642,7 +1647,6 @@ fn build_runtime_cleanup_plan( blocking_reason, pinned, retention_state: record.retention_state.clone(), - lifecycle_state: record.lifecycle_state.clone(), linked_workdir_ids: links.iter().map(|link| link.workdir_id.clone()).collect(), running_linked: is_running, estimated_reclaim_bytes: None, @@ -1667,7 +1671,7 @@ fn build_runtime_cleanup_plan( .collect::>(); let linked_running_worker_ids = linked_workers .iter() - .filter(|worker| worker.lifecycle_state == "running") + .filter(|worker| live_running_worker_ids.contains(&worker.worker_id)) .map(|worker| worker.worker_id.clone()) .collect::>(); let pinned_linked = linked_workers @@ -2913,13 +2917,10 @@ async fn stop_runtime_worker( .stop_worker(&runtime_id, &worker_id, request) .map_err(|err| err.into_error())?; let backend_id = backend_worker_id(&runtime_id, &worker_id); - if let Some(mut record) = api + if let Some(record) = api .store .get_worker_registry(&api.config.workspace_id, backend_id.as_str())? { - record.lifecycle_state = "stopped".to_string(); - record.updated_at = now_registry_timestamp(); - api.store.upsert_worker_registry(&record)?; sync_linked_workdir_after_worker_stop(&api, &runtime_id, &record)?; } Ok(Json(result)) @@ -3879,7 +3880,6 @@ fn record_worker_summary( runtime_worker_id: worker.worker_id.as_str().to_string(), display_name: display_name.to_string(), profile, - lifecycle_state: worker.status.clone(), retention_state: existing .as_ref() .map(|record| record.retention_state.clone()) @@ -3909,8 +3909,8 @@ fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary host_id: "backend-registry".to_string(), role: None, label: record.display_name.clone(), - status: record.lifecycle_state.clone(), - state: record.lifecycle_state.clone(), + status: "archived".to_string(), + state: "archived".to_string(), last_seen_at: Some(record.updated_at.clone()), pinned: record.retention_state == "pinned", retention_state: record.retention_state.clone(), @@ -3949,8 +3949,6 @@ fn merge_worker_registry_projection( .cloned() .unwrap_or_else(|| worker_summary_from_registry(record)); summary.label = record.display_name.clone(); - summary.status = record.lifecycle_state.clone(); - summary.state = record.lifecycle_state.clone(); summary.profile = record.profile.clone(); summary.pinned = record.retention_state == "pinned"; summary.retention_state = record.retention_state.clone(); @@ -4759,7 +4757,6 @@ mod tests { runtime_worker_id: "worker-1".to_string(), display_name: "Archived Worker".to_string(), profile: Some("builtin:coder".to_string()), - lifecycle_state: "stopped".to_string(), retention_state: "pinned".to_string(), transcript_ref: Some("runtime://embedded/workers/worker-1/transcript".to_string()), session_ref: None, @@ -4792,7 +4789,8 @@ mod tests { let projected = merge_worker_registry_projection(None, &worker, vec![link], &[workdir]); - assert_eq!(projected.status, "stopped"); + assert_eq!(projected.status, "archived"); + assert_eq!(projected.state, "archived"); assert_eq!( projected.working_directory.as_ref().unwrap().status, WorkingDirectoryStatusKind::Removed @@ -5341,7 +5339,6 @@ mod tests { fn seed_cleanup_worker( api: &WorkspaceApi, runtime_worker_id: &str, - lifecycle_state: &str, retention_state: &str, ) -> String { let worker_id = backend_worker_id("runtime-test", runtime_worker_id); @@ -5354,7 +5351,6 @@ mod tests { runtime_worker_id: runtime_worker_id.to_string(), display_name: runtime_worker_id.to_string(), profile: None, - lifecycle_state: lifecycle_state.to_string(), retention_state: retention_state.to_string(), transcript_ref: None, session_ref: None, @@ -5525,12 +5521,12 @@ mod tests { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; - let pinned = seed_cleanup_worker(&api, "worker-pinned", "stopped", "pinned"); - let running = seed_cleanup_worker(&api, "worker-running", "running", "normal"); + let pinned = seed_cleanup_worker(&api, "worker-pinned", "pinned"); + let unobserved = seed_cleanup_worker(&api, "worker-unobserved", "normal"); seed_cleanup_workdir(&api, "workdir-dirty", "present", "dirty"); seed_cleanup_workdir(&api, "workdir-removed", "removed", "clean"); seed_cleanup_link(&api, pinned.as_str(), "workdir-dirty"); - seed_cleanup_link(&api, running.as_str(), "workdir-removed"); + seed_cleanup_link(&api, unobserved.as_str(), "workdir-removed"); let plan = build_runtime_cleanup_plan(&api, "runtime-test") .unwrap_or_else(|err| panic!("cleanup plan: {}", err.error)); @@ -5553,11 +5549,8 @@ mod tests { running_linked_workdir.action, CleanupTargetKind::WorkdirRecordDelete ); - assert!(running_linked_workdir.running_linked); - assert_eq!( - running_linked_workdir.blocking_reason.as_deref(), - Some("workdir is linked to a running Worker") - ); + assert!(!running_linked_workdir.running_linked); + assert_eq!(running_linked_workdir.blocking_reason.as_deref(), None); let dirty_workdir = plan .workdirs .iter() @@ -5574,7 +5567,7 @@ mod tests { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; - let worker = seed_cleanup_worker(&api, "worker-pinned", "stopped", "pinned"); + let worker = seed_cleanup_worker(&api, "worker-pinned", "pinned"); let plan = build_runtime_cleanup_plan(&api, "runtime-test") .unwrap_or_else(|err| panic!("cleanup plan: {}", err.error)); let target = plan diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index d0ad0842..47b255f0 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -32,6 +32,11 @@ const MIGRATIONS: &[Migration] = &[ name: "backend worker workdir registry schema", apply: create_worker_workdir_registry_tables, }, + Migration { + version: 4, + name: "remove durable worker lifecycle state", + apply: remove_worker_registry_legacy_live_state_column, + }, ]; struct Migration { @@ -58,7 +63,6 @@ pub struct WorkerRegistryRecord { pub runtime_worker_id: String, pub display_name: String, pub profile: Option, - pub lifecycle_state: String, /// Retention state is explicit so `pinned` can be represented before prune exists. pub retention_state: String, pub transcript_ref: Option, @@ -244,15 +248,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore { conn.execute( r#"INSERT INTO worker_registry ( workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile, - lifecycle_state, retention_state, transcript_ref, session_ref, summary_ref, + retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) ON CONFLICT(workspace_id, worker_id) DO UPDATE SET runtime_id = excluded.runtime_id, runtime_worker_id = excluded.runtime_worker_id, display_name = excluded.display_name, profile = excluded.profile, - lifecycle_state = excluded.lifecycle_state, retention_state = CASE WHEN worker_registry.retention_state = 'pinned' AND excluded.retention_state = 'normal' THEN worker_registry.retention_state @@ -270,7 +273,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.runtime_worker_id, record.display_name, record.profile, - record.lifecycle_state, record.retention_state, record.transcript_ref, record.session_ref, @@ -543,7 +545,7 @@ fn read_worker_workdir_link_record( fn worker_registry_select_sql(where_clause: &str) -> String { format!( "SELECT workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile, \ - lifecycle_state, retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, \ + retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, \ created_at, updated_at FROM worker_registry {where_clause}" ) } @@ -556,14 +558,13 @@ fn read_worker_registry_record(row: &rusqlite::Row<'_>) -> rusqlite::Result Result<()> { + if !table_exists(conn, "worker_registry")? + || !table_columns(conn, "worker_registry")? + .iter() + .any(|column| column == "lifecycle_state") + { + return Ok(()); + } + + conn.execute_batch( + r#" + CREATE TABLE worker_registry_v4 ( + workspace_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + runtime_worker_id TEXT NOT NULL, + display_name TEXT NOT NULL, + profile TEXT, + retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')), + transcript_ref TEXT, + session_ref TEXT, + summary_ref TEXT, + diagnostics_ref TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, worker_id), + UNIQUE (workspace_id, runtime_id, runtime_worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + INSERT INTO worker_registry_v4 ( + workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile, + retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, + created_at, updated_at + ) + SELECT + workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile, + retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, + created_at, updated_at + FROM worker_registry; + DROP TABLE worker_registry; + ALTER TABLE worker_registry_v4 RENAME TO worker_registry; + CREATE INDEX IF NOT EXISTS idx_worker_registry_workspace_updated + ON worker_registry(workspace_id, updated_at DESC); + "#, + )?; + Ok(()) +} + fn create_schema_v0_tables(conn: &Connection) -> Result<()> { conn.execute_batch( r#" @@ -1010,7 +1058,7 @@ mod tests { let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 3); + assert_eq!(store.schema_version().await.unwrap(), 4); let record = WorkspaceRecord { workspace_id: "local-dev".to_string(), @@ -1022,7 +1070,7 @@ mod tests { store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 3); + assert_eq!(reopened.schema_version().await.unwrap(), 4); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -1137,6 +1185,25 @@ mod tests { "caused_by_event_id", ], ); + assert_columns( + &conn, + "worker_registry", + [ + "workspace_id", + "worker_id", + "runtime_id", + "runtime_worker_id", + "display_name", + "profile", + "retention_state", + "transcript_ref", + "session_ref", + "summary_ref", + "diagnostics_ref", + "created_at", + "updated_at", + ], + ); assert_columns( &conn, "artifacts", @@ -1213,7 +1280,7 @@ mod tests { .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 3); + assert_eq!(store.schema_version().await.unwrap(), 4); store .with_conn(|conn| { @@ -1319,7 +1386,6 @@ mod tests { runtime_worker_id: "browser-1".to_string(), display_name: "Browser 1".to_string(), profile: Some("builtin:companion".to_string()), - lifecycle_state: "idle".to_string(), retention_state: "pinned".to_string(), transcript_ref: Some("runtime://embedded/workers/browser-1/transcript".to_string()), session_ref: None, @@ -1330,12 +1396,10 @@ mod tests { }; store.upsert_worker_registry(&worker).unwrap(); let mut runtime_sync_worker = worker.clone(); - runtime_sync_worker.lifecycle_state = "running".to_string(); runtime_sync_worker.retention_state = "normal".to_string(); runtime_sync_worker.updated_at = "5".to_string(); store.upsert_worker_registry(&runtime_sync_worker).unwrap(); let mut expected_worker = worker.clone(); - expected_worker.lifecycle_state = "running".to_string(); expected_worker.updated_at = "5".to_string(); let workdir = WorkdirRegistryRecord { diff --git a/web/workspace/src/lib/workspace-sidebar/types.ts b/web/workspace/src/lib/workspace-sidebar/types.ts index dc9db023..b8c9c6fc 100644 --- a/web/workspace/src/lib/workspace-sidebar/types.ts +++ b/web/workspace/src/lib/workspace-sidebar/types.ts @@ -163,7 +163,6 @@ export type CleanupWorkerCandidate = { blocking_reason?: string | null; pinned: boolean; retention_state: string; - lifecycle_state: string; linked_workdir_ids: string[]; running_linked: boolean; estimated_reclaim_bytes?: number | null; diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte index 8186640f..17f20b4c 100644 --- a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte @@ -127,7 +127,7 @@ /> Delete Worker registry row: {candidate.runtime_worker_id} - {candidate.lifecycle_state}; {candidate.retention_state}; linked Workdirs {candidate.linked_workdir_ids.length} + {candidate.retention_state}; linked Workdirs {candidate.linked_workdir_ids.length} {#if candidate.blocking_reason}Blocked: {candidate.blocking_reason}{/if}