Skip to content

refactor: isolate worker stdio and stream tool logs - #210

Draft
Emin017 wants to merge 55 commits into
mainfrom
emin/refactor-cli-worker
Draft

refactor: isolate worker stdio and stream tool logs#210
Emin017 wants to merge 55 commits into
mainfrom
emin/refactor-cli-worker

Conversation

@Emin017

@Emin017 Emin017 commented Aug 12, 2026

Copy link
Copy Markdown
Member

Completes the executor log-streaming refactor: executors never write step
log files — they write bytes to fd 1/2 plus versioned step markers on fd 2,
and each client archives per-step logs. Includes the v1 marker protocol
(docs/specification/marker-protocol.md), the end-marker ordering fix, full
CLI unification on the worker (all ecc run variants), and the deletion of
the server-side step-log tail machinery.

⚠️ Rollout warning: bumping the ecos-studio submodule pin to this ecc
head without the accompanying GUI PR breaks GUI live step logs (the GUI's
Electron archiver is what restores them). The superproject pin bump lands
in the GUI PR, atomically with the archiver.

Validation

@Emin017
Emin017 force-pushed the emin/refactor-cli-worker branch from 85e5db4 to b90817b Compare August 12, 2026 01:40
@Emin017
Emin017 requested a review from Yell-walkalone August 12, 2026 03:11
@Emin017
Emin017 marked this pull request as ready for review August 12, 2026 03:11
@Emin017 Emin017 added enhancement New feature or request cleanup labels Aug 12, 2026
@Emin017 Emin017 added this to the 0.1.0-alpha.9 milestone Aug 12, 2026
@Emin017 Emin017 linked an issue Aug 12, 2026 that may be closed by this pull request
5 tasks
Emin017 added 13 commits August 18, 2026 11:31
Worker response correlation now matches by request_id; notifications are
queued separately. Process-group cleanup caches pgid at start and signals
the group even after leader exits. Operation-scoped repair only touches
the named step.

Production stdio_server.main() installs StdioIsolation permanently;
per-handler redirect_stdout_to_stderr removed from rpc_dispatch (now a
no-op under permanent isolation).

LogStreamReader archives unknown marker events as raw data instead of
silently discarding them. Archive I/O errors are surfaced through
state.error.
…repair, and marker matching

Response correlation: separate id-keyed pending-response store from
notification queue. read_response checks pending store before reading
stdout. All decoded messages in every batch are preserved.

Process-group escalation: check group liveness (killpg signal 0) after
each signal+wait. Continue SIGTERM/SIGKILL even when proc.wait() has
already returned but the group still has live members.

Flow repair: require active_step (no unscoped fallback). Check
json_write return value and raise OSError on write failure.

Log stream: track active step/tool in state. Only a matching end marker
closes the archive. Mismatched end markers are archived as raw data
without changing state.
…machine markers, and emit from EngineFlow

Response envelope validation: require jsonrpc=="2.0" and exactly one of
result or error (with code+message) before storing or returning a
response. Invalid envelopes raise WorkerProcessError.

Process-group escalation: replace proc.wait()-based escalation with
group-liveness polling via os.killpg(pgid, 0) with deadlines. After
each signal, wait for the group to exit before escalating. Descendants
that handle SIGTERM gracefully are not SIGKILL'd.

Log stream state machine: only accept begin markers while inactive (no
active step). A begin while active is archived as raw data without
state change. Close archive handle properly on write failure.

Step marker emission: EngineFlow.run_step() emits begin/end markers on
stderr around tool execution. Begin after Ongoing persistence, end in
finally path.
Worker:
- Reap leader during group-liveness waits (zombie no longer keeps group
  visible; graceful shutdown completes before forceful deadline)
- Validate response id as int|str|None; reject booleans and non-scalars

Flow:
- Move marker end after ALL step finalization (metrics, state persist,
  layout, DB cleanup, observer) via outer try/finally

LogStream:
- Archive close exception safety: close in finally regardless of flush

New:
- worker_operation.py: typed RunOperation orchestrator integrating
  WorkerClient + LogStreamReader + repair into OperationResult
- Crash path: terminate group, drain reader, repair flow.json, return
  typed failure

Tests:
- Elapsed-time regression proving graceful terminate < 5s
- Response id=true and id=[1] rejection
- Operation orchestrator: success, RPC error, crash+repair, archive
Replace the fake one-RPC wrapper with the canonical session sequence:
rpc.hello → workspace.open → flow.run → rpc.shutdown → EOF wait.

Key changes:
- Default argv now launches `ecc rpc serve --stdio --persistent-db`
- Graceful shutdown via rpc.shutdown RPC, not signals
- Archive completion is a required condition for success (archive
  error, reader timeout, or unmatched begin marker all force failure)
- Protocol failures (dead worker, invalid envelope, EOF) route through
  crash recovery with flow.json repair
- Non-object JSON marker payloads no longer crash parse_marker
- LogStreamReader gains a `completed` property for checked drain

Tests rewritten to exercise the full multi-request lifecycle with
hello/open/run/shutdown, plus crash repair, protocol failure recovery,
archive error detection, and non-object marker resilience.
…down

- Send `directory` (not `path`) in workspace.open params to match
  WorkspaceOpenRequest schema
- Extract workspaceId from open response and inject it into subsequent
  flow request params as workspace_id
- Validate rpc.shutdown response (require result.ok is True) before
  waiting for process exit
- Add real-server lifecycle tests proving hello/open/shutdown through
  the installed ecc rpc serve --stdio --persistent-db binary
- Add contract tests asserting directory field and workspace_id injection
Wire the non-interactive `ecc run` path through the isolated worker
process (RunOperation → flow.run RPC) instead of calling
EngineFlow.run_steps() directly in-process. Falls back to direct
execution if the worker binary is unavailable.

Also fixes strict shutdown validation (ok is True, not truthiness) and
replaces the false-positive real workspace test with the canonical
minimal_ics55_pdk_factory fixture that requires success.
…fallback

Wire a canonical workspace step-log resolver into the production worker
route so that RunOperation archives EDA output to the correct step log
paths (<workspace>/<step>_<tool>/log/<step>.log).

Remove the binary-missing fallback to engine_flow.run_steps() — a
missing worker binary now returns a structured OperationResult with
error detail instead of silently falling back to in-process execution.

Propagate OperationResult failure fields (error, exit_code,
repaired_steps) into the CLI CommandResult error records for richer
failure diagnostics.
…nt, and resilient drain

LogStreamReader now accepts a valid_steps allowlist built from flow.json.
Markers with (step, tool) pairs not in the set are treated as ordinary
stderr data, preventing untrusted marker strings from switching archive
ownership.

After resolving a path, enforce that it resolves under workspace_dir
before opening the archive file. This prevents path traversal via
crafted marker step names like "../../escape".

Resolver and on_output callback exceptions are now isolated: first
error is recorded, the failed sink is disabled, and draining continues
to EOF so pipe backpressure cannot deadlock the worker.

The production CLI route reads flow.json to build the allowlist before
starting RunOperation.
@Emin017
Emin017 force-pushed the emin/refactor-cli-worker branch from 54055d4 to a1e53f1 Compare August 18, 2026 07:09
…ites

Add a protocol version field (v: 1) to step marker payloads; parsers
reject frames with a missing or unsupported version as ordinary bytes.

Relocate the end marker in EngineFlow.run_step so it fires after all
step-scoped writes (final state persistence, [RESULT], QOR, layout
snapshot, db cleanup) and before the completion observer notification,
so a consumer that has read the end marker has seen every byte of the
step.
Normative specification of the step marker byte-stream protocol: frame
format and v1 payload, consumer/producer semantics, the ordering
guarantee (end after all step-scoped writes, before completion notify),
the single-producer invariant, the archive path layout, and the DEC-1
protocol change making the GUI the only live-log consumer.
…alls to RunOperation

FlowRunStepRequest gains an optional reset_dependents field (additive,
mirroring operation.start_step), and the flow.run_step handler forwards
it so direct step reruns can invalidate the downstream suffix.

RunOperation.run_sequence executes an ordered list of RPC calls in one
worker session, stopping at the first failed RPC: remaining calls are
skipped, the session is still shut down gracefully and drained, and the
returned OperationResult describes the failing call.
All execution paths now go through RunOperation; the TTY distinction
only selects UI rendering.

- run --workspace maps --resume/--from onto flow.run_step with
  reset_dependents plus a follow-up flow.run in one worker session, and
  --only/--force onto a single flow.run_step; the no-op cases (already
  successful selections) keep their current records.
- run_flow_with_progress is rewritten around the reader callbacks:
  begin markers drive step transitions, on_output drives the throttled
  live line, and per-step final states refresh from flow.json on each
  begin marker and once at operation end.
- LogStreamReader gains an on_step_event callback fired on matched
  begin/end markers; RunOperation forwards it.
- Delete preserve_cli_stdio, the log monitor, the incremental log
  tail, redirect_stdio_to_file, and the in-process rerun execution.
Executors never write step log files, so there is nothing for the
runtime server to tail: remove the step log tail thread, the delta
publisher, the final log reader, and their call sites. step.completed
keeps step, tool, state, stepCommitId, workspaceRevision, and the
render gate; live step.log events and finalLog are now synthesized by
the archiving client instead of the executor.
Emin017 and others added 8 commits August 19, 2026 09:48
parse_marker already catches UnicodeDecodeError, but the rejection was
not pinned by a test. Add the normative case so the Python reader and
the TS archiver (fatal TextDecoder) are held to the same rule: a frame
whose payload is not valid UTF-8 is ordinary stream bytes, never a
marker.
Cover the resume/suffix dependency path the filter exists for: with
executable_steps active, a non-executing Success predecessor is built
without a dependency check, so its outputs still chain into the
executing successor's inputs and its missing tool marks nothing
Incomplete.
Round-5 re-review P3 follow-ups: pin rejection of non-object JSON
payloads ([], null, 42, "hello", true) in the normative parse matrix,
and give the end-marker ordering step a feature path so the QOR/metrics
refresh is explicitly ordered before the end marker.
set_state returned True even when its save failed, and run_step's
redundant second save only flipped a local variable — a failed final
save left the canonical record (and possibly flow.json) at Success
while reporting Imcomplete. set_state now returns the real save result,
run_step performs one final save, and on failure the canonical record
is downgraded in memory, the end marker suppressed, and Imcomplete
reported. The regression uses a real Flow fixture so the failing save
is the one persisting the record: exact save count, no end marker,
Imcomplete return/observer/record, Ongoing on disk.
The invalidate_dependents path persisted and deleted the target through
_prepare_steps_for_rerun, then saved downstream records separately —
a second-save failure left target artifacts deleted and the session
records half-mutated. _prepare_steps_for_rerun now validates paths,
snapshots every affected record, applies target reset plus downstream
state invalidation, persists once, and restores the snapshots before
raising on failure; artifacts are cleared only after the save succeeds.
The now-unused _invalidate_step_records is removed. Regressions: a
three-step save failure pins restored records, untouched artifacts,
and a single save attempt; an in-process flow_run_step run with a real
EngineFlow proves a failed final save leaves no Success record and the
next non-rerun call re-executes the step.
Deleting redirect_stdio_to_file broke agent/engine.py at import time
(main pytest collects only test/, so the break was invisible), and its
run_step still redirected executor stdio into step log files with no
markers — against the protocol. The agent now emits begin/end markers
exactly like EngineFlow.run_step, never touches step log files, and its
_finish_step returns the authoritative save result: a failed final save
downgrades the canonical record and suppresses the end marker. New
tests pin the marker ordering around step writes and the failed-save
suppression. The three-step invalidation regression now also seeds
non-default record metadata and asserts identity-preserving rollback of
the complete records.
AgentEngineFlow duplicated the full step lifecycle — selection, Ongoing
transition, marker emission, memory tracking, tool invocation, final
save, downgrade, post-processing, db cleanup — which is how the
redirect_stdio_to_file deletion left it broken and protocol-inconsistent
for four rounds. EngineFlow.run_step now invokes the tool through
_invoke_step_tool and derives the state through _derive_step_state; the
base hooks preserve existing behavior exactly. AgentEngineFlow keeps
only its DRC insertion and two hook overrides (run_agent_step, with
False -> Imcomplete, Invalid passthrough, True|Success -> artifact
check). New agent regression proves the inherited lifecycle drives the
agent hook and opens no step log file even when one is declared.
@Emin017
Emin017 marked this pull request as draft August 19, 2026 07:32
Emin017 added 21 commits August 19, 2026 16:19
The run_step+flow.run sequence let the trailing unscoped flow.run resume
from the first non-success step — with a failed step before the --from
boundary, it executed and mutated steps outside the requested suffix.
The suffix is now driven as explicit per-step run_step calls (boundary
step with reset_dependents, then each persisted successor), preserving
rerun.run_from's exact scoping and stop-on-first-failure ordering.
Wiring tests updated to the stepwise contract, plus a regression with a
failed step before the --from boundary proving it is never called.
Direct EngineFlow execution (agent candidate reruns, documented examples)
emitted markers to fd 2 with no client to consume them: raw frames leaked
to the caller's stderr and no per-step archive was written. The new
archive_own_step_logs context redirects the process's own fd 2 through a
pipe so a LogStreamReader archives step bytes and consumes markers while
echoing everything to the original stderr — the executor still never
opens a log file; the client role just runs in-process. The agent's
candidate rerun path uses it, and the teardown closes the saved stderr
only after the reader drains, pinned by a capfd regression.
archive_own_step_logs redirected only fd 2, but tool subprocess stdout
and native tool logging write fd 1 — in-process runs (agent candidate
reruns, direct EngineFlow examples) lost those bytes from the step
archives. Both descriptors now share the pipe, matching the merged
stream the CLI worker's stdio isolation produces; markers stay on fd 2
and every byte echoes to the original stderr. The regression now also
pins fd 1 bytes landing in the archive in write order.
The public rerun helpers (run_from/run_only/run_resume) execute steps
in-process with no client to consume the marker stream: step logs stayed
empty and raw ECC-STEP frames leaked to the caller's terminal.
_run_selected now wraps execution in archive_own_step_logs so direct
callers get the same client-side archival as worker runs. A regression
drives real marker+byte writes through run_from and asserts the archive
contents and a marker-free terminal.
…ir failures

Two worker failure paths left persisted state inconsistent with the
reported result. When the worker completed but the reader archived with
errors (or an unmatched begin), the step's Success record survived while
the CLI reported failure — resume would skip the step and never recreate
the missing log; the error branch now repairs the affected record to
Incomplete. And a crash-repair that cannot persist (disk full,
permissions) was silently suppressed; both error paths now append the
repair failure to the result error instead. Regressions pin the Success
downgrade on archive failure and the surfaced repair failure on crash.
…e failures

- step_log_archive_resolver mirrors the sizer builder's sanitized
  directory (Timing optimization -> timing_optimization_sizer), so worker
  success no longer writes to a directory the built step never owned.
- Every remaining direct EngineFlow entry point (integration conftest,
  both gcd examples) now runs inside archive_own_step_logs; the conftest's
  obsolete fd save/restore workaround is removed.
- The reader records error_step at the first archive error, and all three
  worker failure paths (clean-shutdown errors, RPC error, crash) reconcile
  through one _reconcile_step_state helper — a Success record never
  survives a missing or incomplete archive.
- In-process rerun paths (engine.rerun helpers, agent candidate reruns)
  now fail and downgrade the record when archival fails or a begin is
  unmatched, instead of reporting success over a missing log.
- Rerun preparation moves to runtime/rerun_prepare.py, keeping
  workspace_api.py an adapter per the module-size rule.
…ample

- LogStreamReader drained pipes with read(8192), which blocks until the
  buffer fills or EOF: steps emitting less than 8 KiB showed no live
  progress until they exited. read1 delivers whatever the pipe currently
  holds (with a plain-read fallback); a pipe-based regression proves a
  short line is delivered while the writer stays open.
- docs/examples/gcd/ics55flow.py was the last shipped entry point calling
  run_steps() bare; it now runs inside archive_own_step_logs like the
  other direct-run examples.
…pipe

- _run_selected reconciles the reader state in an exception path too: a
  step raising after its begin marker no longer skips the archive
  downgrade before the exception propagates.
- Both downgrade sites now go through set_state, so the repair persists
  through the authoritative save and a failed save is surfaced (logged)
  instead of leaving flow.json at Success over a missing archive.
- archive_own_step_logs closes the pipe read stream at teardown; long
  runs of in-process reruns no longer accumulate pipe fds toward EMFILE.
A regression proves a post-begin exception still downgrades the record.
- prepare_steps_for_rerun restores the persisted records when post-save
  cleanup (artifact delete, subflow/checklist reset) fails midway, so the
  workspace is never left with Unstart states over half-deleted outputs.
- EngineFlow.run_step downgrades the persisted Ongoing when the begin
  marker cannot reach fd 2 — no reader ever saw the step, so worker
  recovery could never identify it.
- Runtime flow_run_step appends the global flow/status log after each
  executed step, restoring the log_flow side effect the in-process rerun
  helpers used to provide for --workspace/--from/--resume runs.
Regressions pin the cleanup rollback and the begin-marker downgrade.
Review rounds kept finding the same gap: direct EngineFlow execution is a
documented Python API, but per-callsite wrapping cannot hold. run_steps now
wraps itself in archive_own_step_logs, so bare documented scripts archive
step logs and keep markers off the terminal. The context passes through
untouched when an outer client owns the stream (the stdio server entry
marks itself via mark_external_log_client) or when nested inside another
archive context, preserving the single-producer invariant for worker,
sidecar, agent-candidate, and rerun-helper paths. The integration conftest
drops its now-redundant explicit wrapper. Regressions pin the nested and
external-client passthroughs.
… and pre-marker crashes

- prepare_steps_for_rerun cleanup is per-step: a mid-cleanup failure keeps
  the persisted Unstart on steps whose artifacts are already gone and rolls
  back only untouched steps, so resume never trusts Success over deleted
  outputs.
- run_steps reconciles the self-archive reader after the loop (and before
  exception propagation): archive failures or unmatched begins downgrade
  the record and return False instead of reporting success over a missing
  log.
- Worker crash recovery falls back to the persisted Ongoing record when no
  stream evidence exists (kill between the Ongoing save and the begin
  marker). Regressions pin all three paths.
- on_output/on_step_event callback exceptions now land in
  LogStreamState.display_error instead of error: a broken renderer no
  longer reads as an archive failure and can no longer downgrade a step
  whose archive is complete.
- downgrade_unarchived_step surfaces an unpersistable downgrade with an
  explicit error log naming the stale Success record, instead of
  reporting a repair that never reached disk.
Regressions pin both: a crashed renderer leaves the operation successful
with the record intact, and a failed downgrade save keeps the disk record
honestly at Success while the operation fails.
…istence

The two callback-resilience contract tests now assert display_error
(display-only failures), and the rerun suite gains a regression proving
an unpersistable downgrade keeps the disk record honestly at Success
while the operation reports failure.
…, reset the self-archive guard

- The agent candidate path reconciles reader evidence before propagating
  a step exception, matching rerun/run_steps: no persisted Success over a
  partial archive.
- RunOperation converts parent SIGTERM into KeyboardInterrupt on the main
  thread (restored on exit), routing through crash recovery so the
  worker's process group and EDA descendants are reaped instead of
  mutating the workspace after the CLI dies.
- archive_own_step_logs setup failure now restores any redirected fds,
  closes opened descriptors, and resets the guard — a failed setup no
  longer poisons later in-process runs.
Regressions pin all three paths.
…suffix tools

- EngineFlow.run_step now also self-archives (passthrough inside worker/
  sidecar processes and nested contexts), so no direct execution path
  leaks markers or leaves step logs unwritten.
- repair_flow_state and downgrade_unarchived_step match on (name, tool):
  a duplicate step name under another tool is no longer downgraded by an
  unrelated archive failure; the reader carries error_tool for the
  evidence pair.
- The CLI preflights every selected step's tool before the first worker
  call, so an unavailable tool fails the run before reset_dependents
  invalidates and clears the suffix.
Regressions pin the pair matching, the preflight, and the direct-run
archival.
… the 700-line bar

- engine/runner.py: EngineFlowRunner mixin owns the step/flow execution
  lifecycle (markers, memory tracking, authoritative final save,
  post-processing, db cleanup, observer/render gate); flow.py keeps the
  data/state/build spine at 321 lines. run_step now reconciles the
  self-archive reader on both success and exception paths.
- cli/command_handlers/workspace_run.py: worker-call construction, suffix
  preflight, and outcome reconciliation move out of project.py (499
  lines). The preflight now catches dependency-check exceptions and covers
  the sizer runtime sentinel (src/sizer_os.tcl), and
  archive_own_step_logs guards descriptor-duplication failures so a broken
  setup releases the guard and restores fds.
Regressions pin direct run_step downgrade, preflight rejection without
mutation, and setup-failure recovery.
…den archive teardown

- A begin rejected by name/containment validation now keeps the marker's
  (step, tool) identity on error_step/error_tool, so failure-path
  reconciliation can find and downgrade the step even though it never
  activated.
- archive_own_step_logs moves the pre-setup flushes into the guarded try
  and releases _SELF_ARCHIVE_ACTIVE in an innermost finally with per-step
  suppression, so any teardown failure (broken descriptor, closed stream)
  still restores fd 1/2 and frees the guard.
Regressions pin the identity preservation and teardown failure recovery.
…flight the worker, split worker-op tests

- WorkerClient.request reads error.data.message (RuntimeServer's channel
  for RuntimeApiError's actionable text) with a fallback to the top-level
  message, so  failures name the failed step instead of a bare
  command_failed.
- archive_own_step_logs teardown joins the reader a second time after the
  stop signal and records a TimeoutError on the reader state when the
  drain never completes, instead of releasing the guard over a live
  reader.
-  preflights worker availability before creating the
  run directory (and before --overwrite deletes an existing one), so a
  missing worker leaves the workspace untouched for a clean retry; the
  orphaned duplicate of the check in project.py is gone.
- test_worker_operation.py splits into lifecycle/sequencing, crash
  recovery, and archive modules over a shared worker_operation_support
  preamble, each below the 700-line review bar.
Regressions pin the nested-message parsing, the preflight (run dir
uncreated; overwrite target preserved), and all split suites pass.
…r host, track completed calls

- Database initialization moves inside the step's marked lifecycle
  (immediately after the begin marker) and the caller-side pre-inits in
  run_steps, rerun._run_selected, runtime _flow_run_step, and the agent
  candidate path are gone: native initialization output now reaches the
  step archive instead of leaking unscoped, per the marker-protocol
  ordering rule the spec now states explicitly.
- EngineFlowRunner declares its host contract (workspace, workspace_steps,
  engine_db, and the record helpers) with class-level annotations and
  TYPE_CHECKING stubs, so type checkers no longer see 45 unknown-member
  errors in the extracted lifecycle.
- RunOperation counts completed worker calls on OperationResult, and the
  workspace-run outcome derives executed/failed steps from that count:
  a worker dying during startup no longer reports already-successful
  selected steps (--from / --only --force) as executed from stale
  flow.json states.
- The worker preflight rejects non-executable binaries (os.access X_OK)
  before --overwrite deletes an existing run.
Regressions pin begin-before-init ordering, the startup-failure outcome,
the executable-bit rejection, and the sizer engine-clearing semantics
under the per-step init.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cleanup enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Tool Abort in ECC Leads to Deadlock in ecc-wrapper

2 participants