When an instance action outlives its observer

An Hosting/InstanceAction run reports itself on its own node: InstanceActionControlPlane watches the node, runs the phases, and folds each phase's progress onto it. That reporting chain is an in-process reactive subscription. The operation it reports on is not — it is a Kubernetes Job, and it keeps going when the process that launched it goes away.

The gap, in one sentence. A run whose observer died read exactly like a run in progress: state: Running, phase: Run operator job, message: ⏳ Step 5/8 — Run operator job…, no finishedAt — and there was nothing on the node to compare the elapsed time against.

The measurement (control instance, read-only, 2026-09-10)

Deployments/memex-restart-20260910-1140-activate-ai16 — a Restart of memex, requested at 11:33:56Z, node version 20:

Field Reading
state Running
phase Run operator job
message ⏳ Step 5/8 — Run operator job…
startedAt 2026-09-10T11:33:56Z
finishedAt absent — nine hours later
jobName absent
last log line 11:36:54Z

🚨 The rollout it was watching SUCCEEDED. Deployments/memex-sample-20260910-1207-settled (12:05:44Z) reports 2/2 ready, 0 restart(s), running meshweaver.azurecr.io/memex-portal-ai:3.0.0-ci.8238. The operation completed and the record never learned it had.

The action's own plan had predicted a different mechanism, and written the warning into its log:

⚠️ The deployment's rollout budget exceeds the 3000-second observation window of one operator job. The job may stop waiting before the rollout is complete. Check the rollout status afterwards.

That window would have expired around 12:24Z. It never got the chance to matter.

Three facts about the code, and only the third is surprising

1. The observer's lifetime is the process's. ProcessProvisionPlan.RunTrack is one subscription held by the per-node hub. Kill the process and the chain is gone, mid-phase, with no write.

2. Nothing ever revisits a Running node. InstanceActionContent.ShouldRun() deliberately excludes Running — the watcher writes to the node it watches, and that exclusion is what stops it scheduling itself. Correct, and it also means an orphaned run is orphaned for ever.

🚨 3. The operator Job's NAME was never written to the node. run.JobName lived exclusively in the watcher's in-process RunState. The content type declared a JobName property and the page rendered an "Operator job" row from it, and nothing ever populated either. So a run whose process went away was not merely unobserved — it was unobservABLE: the one identifier needed to go and ask Kubernetes what happened died with the observer.

And for one action the loss is by construction

A Restart or Roll of the deployment that hosts the control plane rolls away the pods running the control plane. The control instance can never observe its own restart to completion. That is not a race, a slow day or a flake — it is an identity: the observer is inside the thing being replaced.

What the control plane records and does now

A bound, stamped when the job is launched. HostingOperator.ObservationEnd(settings, launchedAt) — the Job's own activeDeadlineSeconds plus the poll's slack — is written to the node as observedUntil, alongside jobName. One definition, two readers: the same function produces Track's own .Timeout(...). A second, separately-derived number would be free to disagree with the observer it claims to describe, which is this bug one level up.

A third state, Unobserved. Not Running (nobody is watching) and not Failed (nothing is known to have broken). Error stays null and FinishedAt stays unset, both deliberately: the only thing that happened is that observation stopped. The action is cleared, so nothing loops — and a person may re-request it, which is the only route back, on purpose.

Adoption on the first emission. A node this hub finds already Running on its very first emission cannot be this process's run, because this hub has only just come into existence. So the watcher re-observes the job the node names:

What the Job says What the run becomes
succeeded Done, with the log saying plainly that the phases after the job did not run
failed Failed, carrying Kubernetes' own reason
still running nothing is written — this process takes over watching it
gone (reaped, never created) Unobserved
unreadable (API refused, operator not configured here) Unobserved
no jobName on the node at all Unobserved, naming a Sample as the read that answers

A derived verdict where nothing has had the chance to record one. The fleet run log (Runs) comes from a query: most of those nodes' hubs are not up, so nothing has adopted them. OutcomeNotMeasured(now) derives the same verdict from observedUntil alone, and the page collapses both sources into ONE sentence — a reader must never have to tell "a process recorded this" from "I worked it out" apart.

An observation that ends without a verdict is no longer reported as a failure. Track's timeout used to emit a terminal failed event saying the job "did not finish within its deadline". The Job carries activeDeadlineSeconds, so a job still alive at that point is impossible: reaching it means the API could not be read, or the object went away. That is now OperatorEvent.Unobserved, and InstanceActionControlPlane.Fold turns the resulting phase failure into Unobserved rather than Failed.

What this deliberately is NOT

What is still open

The relationship to the deploy lane (Systemorph/Memex#188)

Memex#188 is the same shape one layer down: helm-release.yml could not report an outcome for a rollout longer than the 10-minute az aks command invoke limit. It is not the same defect — different repository, different runtime, different window, and a GitHub Actions runner is never killed by the rollout it is watching. It is also largely fixed (Memex#192/#193: helm upgrade without --wait, observation across separate invokes, RED naming the re-run at the budget, and an action: observe dispatch that re-observes later).

Its shipped remedy is the template this page follows: an observation window bounds how long an operation may take, which is the wrong constraint. Report honestly at the budget, and re-observe across the boundary.

The control

Both directions, in InstanceAction/Test/ObserverExpiryTests.cs, because a fix that answered "I don't know" to everything would pass a one-sided test and destroy the control plane:

Direction Assertion
the defect the headline must stop calling an unwatched run 'Running'
the defect an unreadable job yields Unobserved, never Done and never Failed
the defect a lost observation is not a failure
positive a watched run still reads Running
positive the job's own success is evidence, and it is reported
positive an unobserved-observation marker is the ONLY thing that changes this verdict
positive a completed run is Done … and stays Done however long ago its window ended
the trap an absent bound must never be treated as an elapsed one
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.