Skip to content

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

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

refactor: isolate worker stdio and stream tool logs#210
Emin017 wants to merge 35 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 16 commits August 18, 2026 18:07
…get checks

Review follow-ups for the worker-streamed logs delta:

- emit_step_marker now drains C/C++ stdio buffers (flush_cstdio) before
  writing, so native output can never land on the wrong side of a step
  boundary.
- parse_marker rejects JSON boolean versions (true == 1 in Python).
- LogStreamReader validates step/tool names (no separators, dot
  segments, or empty values) and containment before activating a step;
  violating begin frames degrade to ordinary bytes instead of being
  consumed.
- ecc run --workspace --only maps executed steps to flow.run_step with
  rerun: true, restoring the previous clean-artifact contract; --force
  remains the gate for re-executing a successful step.
- Add CLI-level tests that execute against a real worker subprocess:
  --resume suffix execution with archiving, --only single-step
  execution, and non-TTY flow.run archival with no marker leakage.
The documented workspace-mode contract requires a re-executed step to
replace its own artifacts and mark downstream steps Unstart while
keeping their outputs. reset_dependents cannot express this because it
also clears downstream artifacts, so FlowRunStepRequest gains an
additive invalidate_dependents field: the server prepares only the
target step and marks the downstream suffix Unstart in flow.json.

Also resolve the remaining review findings: the log reader now
resolves and validates an archive target exactly once and opens that
validated path, and the C stdio ordering test uses an explicitly
buffered FILE* so it fails without the flush.
- The log reader processes lines with a single split per chunk, trims
  its tail only past twice the cap, shares path_is_within for
  containment, and records first-error-wins through one helper.
- RunOperation's failed-RPC branch reuses _handle_protocol_or_crash.
- The CLI shares one worker-call entry point and one tolerant
  flow.json reader (cli.inspection.discovery.read_flow_json); the TTY
  live line throttles before decoding and sanitizing.
- The rerun preparation collapses the duplicate prepare branches.
- The marker protocol spec now documents the allowlist policy and each
  consumer's failure default.
The producer never inserts a newline before a marker frame, so a frame
can immediately follow tool output that lacks a trailing newline. The
reader now scans for the reserved prefix anywhere in the stream: bytes
before a candidate are data, a trailing partial prefix is held back,
incomplete candidates are bounded, and only valid newline-terminated
v1 frames are consumed. The specification documents the scanning rule
and now shows the exact wire spelling of the prefix.
A flow that raises after the begin marker returns a structured RPC
error from a still-alive worker, leaving flow.json with a stale
Ongoing record. The RPC-error path now performs the same repair as
crash recovery after the reader drains: the active step is marked
Incomplete and the result carries repaired_steps alongside the failing
RPC response.
test_log_stream.py crossed the repository's 700-line review bar at 814
lines. The coverage now lives in three focused modules, every assertion
preserved: test_log_stream.py keeps the reader archiving and resilience
cases, test_log_stream_markers.py holds the parse/emit/boundary-scanning
suite (including the chunked-stream helper), and
test_log_stream_targets.py covers allowlist, sanitization, containment,
resolver, and step-event behavior.
- run_step no longer emits the end marker when the final state could
  not be persisted; the step downgrades to Imcomplete so consumers see
  a crashed step rather than a stale flow.json claiming success.
- repair_flow_state now also repairs a Success record for the step that
  was active at crash time: a Success without a completed end marker
  means the crash interrupted post-processing, so the persisted result
  is not trustworthy.
- Rerun invalidation is atomic again: the invalidation is persisted
  before any artifact directory is deleted, and a failed save refuses
  to modify outputs (matching the previous CLI contract).
- The worker's flow build verifies tool dependencies only for steps
  that will actually execute, so resume/from/only runs reuse successful
  predecessor outputs when an unselected tool is absent.
- The 512-byte marker holdback is now inclusive per the specification;
  exactly-512-byte incomplete candidates are held and consumed.
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
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