You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Flameox currently has more than one durable-operation framework, and long-running features continue to implement lifecycle behavior directly. The code therefore duplicates idempotency, ownership, progress, cancellation, cleanup, terminalization, and restart recovery across operation families.
PR #79 introduced a generic durable operation framework to standardize these semantics, but the abstraction was adopted primarily by capability setup. Detached capture retained a parallel record/status/manager hierarchy, while inference replay/profiling and several publication/recovery paths still own bespoke terminal transitions.
The repeated lifecycle fixes in #75, #77, #87, #104, #118, and #127 are symptoms of this architectural split. The correct fix is not another feature-specific status field; it is one small durable state-machine kernel with typed operation adapters.
Evidence in the current architecture
OperationRunner is nominally generic but semantically capability-specific
However, the supposedly generic status projection contains branches such as record.operation == "capability.setup", and the runner is currently used by capability setup rather than being the lifecycle boundary for other long-running operations. Domain behavior has leaked back into the kernel.
Detached capture implements a parallel lifecycle system
its own cancellation, cleanup, progress, and terminal transitions.
Idempotency lookup scans stored records instead of addressing an atomically unique durable key. That is both an efficiency smell and, more importantly, evidence that idempotency is being implemented as query convention rather than a storage invariant.
Other workflows still hand-code terminalization
Inference replay and profiling create run manifests, execute subprocesses, preserve partial artifacts, translate exceptions, append terminal revisions, publish evidence, and clean staging within large lifecycle methods. Artifact publication, repair, quarantine, and recovery have also required repeated fixes to ensure that interruption leaves a terminal or resumable state.
These services need domain-specific work, but they should not each invent the transport-level lifecycle around that work.
Root cause
The generic abstraction is drawn at the wrong boundary.
domain state — capture/run manifests, evidence publication, capability receipts, inference protocols, artifact registrations, and validation results;
presentation — MCP polling intervals, next-tool guidance, and operation-specific status fields.
When these concerns live in one feature manager, every new operation family creates another subtly different state machine. When they live in one generic model with hard-coded operation-name checks, the core abstraction becomes an untyped union implemented with strings.
Required invariant
Every long-running side-effecting operation uses one durable lifecycle kernel. Domain adapters may define work and typed receipts, but they may not redefine idempotency, ownership, cancellation, terminal-state, or recovery semantics.
For a stable (workspace, operation kind, idempotency key, request intent) tuple, all concurrent and retried starts must converge on one durable operation and at most one side-effecting execution.
Proposed design
1. Define one durable operation envelope
Keep the shared record small and stable. For example:
OperationPayload and OperationReceipt should be discriminated typed unions owned by operation adapters. The lifecycle kernel should not inspect their operation-specific fields.
2. Use an explicit transition table
Do not update lifecycle fields through arbitrary model_copy() calls spread across services. Define and test legal transitions, for example:
The kernel controls transitions and persistence. The adapter performs domain work and returns typed events/receipts. Core code must not contain checks for "capability.setup", "capture", "inference", or other operation names.
4. Make idempotency a storage invariant
A start path must not scan records and then create a new record in a separate step.
Use one of these equivalent approaches:
derive the durable operation key from (workspace_id, kind, idempotency_digest) and atomically create that exact record path;
or store operations in a transactional database with a unique constraint on those fields.
The stored record must also contain request_digest. Reusing the same idempotency key with different intent must fail explicitly rather than reconnect to unrelated work.
If the existing JSON record store remains, its atomic create/CAS semantics can enforce this without requiring an immediate database migration. The architectural requirement is uniqueness at write time, not a particular database.
5. Standardize ownership, heartbeat, and recovery
All active operations should use the same lease model:
owner identity;
lease acquisition under CAS/transaction;
periodic heartbeat;
explicit expiry;
restart reconciliation;
proof that an old owner cannot continue advancing the record after lease loss.
Recovery is operation-specific, but the decision boundary is not. The adapter may choose resume, observe_external_work, finalize_from_domain_record, fail_with_recovery, or cannot_recover; the kernel persists and exposes that decision consistently.
6. Keep domain records authoritative
This issue must not replace RunManifest, artifact registrations, evidence generations, or inference protocol identities with one generic operation blob.
For capture and inference:
the operation envelope owns request/idempotency/lease/cancellation/recovery semantics;
the run manifest remains the authoritative scientific/execution evidence record;
the operation payload links to the run ID and current domain revision;
terminalization reconciles the operation receipt with the already persisted run state.
This avoids two competing copies of execution truth while still giving MCP/CLI one durable polling protocol.
7. Use one bounded progress/event model
Persist only a bounded recent progress history plus the current phase/aggregate. Events should be typed and sequence-numbered so callers can poll incrementally without unbounded records.
Progress reporting should have one definition across capabilities, capture, inference, reduction, and maintenance operations:
known work: monotonic completed/total;
unknown duration: phase and elapsed time, no invented percentage;
cancellation: requested, observed, cleanup in progress, terminal;
retry/recovery: explicit attempt and ownership metadata.
8. Apply the kernel incrementally
Recommended order:
Remove capability-specific branches from operations.py by introducing the adapter boundary.
Port DetachedCaptureManager onto the same kernel while preserving current MCP contracts.
Move inference replay/profiling to durable operations, reusing the authorized-plan work from the plan-integrity issue.
Move long artifact publication/recovery/repair operations where they benefit from polling, idempotency, and restart recovery.
Delete parallel record/status managers once migrations and compatibility windows are complete.
Non-goals
Do not force bounded synchronous reads into durable operations.
Do not erase domain-specific states or evidence models.
Do not create a universal workflow language or scheduler.
Do not serialize arbitrary Python callbacks as durable workflow definitions.
SQLite uniqueness and ON CONFLICT can make an idempotency key a write-time invariant instead of a read-then-write convention: https://www.sqlite.org/lang_conflict.html
Summary
Flameox currently has more than one durable-operation framework, and long-running features continue to implement lifecycle behavior directly. The code therefore duplicates idempotency, ownership, progress, cancellation, cleanup, terminalization, and restart recovery across operation families.
PR #79 introduced a generic durable operation framework to standardize these semantics, but the abstraction was adopted primarily by capability setup. Detached capture retained a parallel record/status/manager hierarchy, while inference replay/profiling and several publication/recovery paths still own bespoke terminal transitions.
The repeated lifecycle fixes in #75, #77, #87, #104, #118, and #127 are symptoms of this architectural split. The correct fix is not another feature-specific status field; it is one small durable state-machine kernel with typed operation adapters.
Evidence in the current architecture
OperationRunneris nominally generic but semantically capability-specificsrc/flameox/application/operations.pydefines useful common concepts:However, the supposedly generic status projection contains branches such as
record.operation == "capability.setup", and the runner is currently used by capability setup rather than being the lifecycle boundary for other long-running operations. Domain behavior has leaked back into the kernel.Detached capture implements a parallel lifecycle system
src/flameox/application/detached.pyseparately defines:DetachedProgress;DetachedCaptureRecord;DetachedRecovery;DetachedCaptureStatus;DetachedCaptureManager;Idempotency lookup scans stored records instead of addressing an atomically unique durable key. That is both an efficiency smell and, more importantly, evidence that idempotency is being implemented as query convention rather than a storage invariant.
Other workflows still hand-code terminalization
Inference replay and profiling create run manifests, execute subprocesses, preserve partial artifacts, translate exceptions, append terminal revisions, publish evidence, and clean staging within large lifecycle methods. Artifact publication, repair, quarantine, and recovery have also required repeated fixes to ensure that interruption leaves a terminal or resumable state.
These services need domain-specific work, but they should not each invent the transport-level lifecycle around that work.
Root cause
The generic abstraction is drawn at the wrong boundary.
The repository currently mixes:
When these concerns live in one feature manager, every new operation family creates another subtly different state machine. When they live in one generic model with hard-coded operation-name checks, the core abstraction becomes an untyped union implemented with strings.
Required invariant
For a stable
(workspace, operation kind, idempotency key, request intent)tuple, all concurrent and retried starts must converge on one durable operation and at most one side-effecting execution.Proposed design
1. Define one durable operation envelope
Keep the shared record small and stable. For example:
OperationPayloadandOperationReceiptshould be discriminated typed unions owned by operation adapters. The lifecycle kernel should not inspect their operation-specific fields.2. Use an explicit transition table
Do not update lifecycle fields through arbitrary
model_copy()calls spread across services. Define and test legal transitions, for example:The exact states can differ, but the following rules should be encoded centrally:
3. Replace operation-name conditionals with typed adapters
Define an adapter protocol such as:
The kernel controls transitions and persistence. The adapter performs domain work and returns typed events/receipts. Core code must not contain checks for
"capability.setup","capture","inference", or other operation names.4. Make idempotency a storage invariant
A start path must not scan records and then create a new record in a separate step.
Use one of these equivalent approaches:
(workspace_id, kind, idempotency_digest)and atomically create that exact record path;The stored record must also contain
request_digest. Reusing the same idempotency key with different intent must fail explicitly rather than reconnect to unrelated work.If the existing JSON record store remains, its atomic create/CAS semantics can enforce this without requiring an immediate database migration. The architectural requirement is uniqueness at write time, not a particular database.
5. Standardize ownership, heartbeat, and recovery
All active operations should use the same lease model:
Recovery is operation-specific, but the decision boundary is not. The adapter may choose
resume,observe_external_work,finalize_from_domain_record,fail_with_recovery, orcannot_recover; the kernel persists and exposes that decision consistently.6. Keep domain records authoritative
This issue must not replace
RunManifest, artifact registrations, evidence generations, or inference protocol identities with one generic operation blob.For capture and inference:
This avoids two competing copies of execution truth while still giving MCP/CLI one durable polling protocol.
7. Use one bounded progress/event model
Persist only a bounded recent progress history plus the current phase/aggregate. Events should be typed and sequence-numbered so callers can poll incrementally without unbounded records.
Progress reporting should have one definition across capabilities, capture, inference, reduction, and maintenance operations:
8. Apply the kernel incrementally
Recommended order:
operations.pyby introducing the adapter boundary.DetachedCaptureManageronto the same kernel while preserving current MCP contracts.Non-goals
Storage and compatibility considerations
operations/and detached-capture records.Acceptance criteria
Best-practice references
ON CONFLICTcan make an idempotency key a write-time invariant instead of a read-then-write convention: https://www.sqlite.org/lang_conflict.html