Summary
When running task chains (runner run -s …, runner run -p …, or runner install [tasks]), users have no visibility into how long each task took. Add per-task duration reporting across sequential and parallel execution paths (and optionally single-task runs for consistency).
Motivation
Chain mode is increasingly used for local dev and CI (runner run -p lint test build). Failures and slow tasks are easier to diagnose when each item reports wall-clock duration on completion — similar to turbo run / nx run-many task summaries, or time appended to each step.
Current architecture (where to hook)
All chain dispatch funnels through chain::exec::run_chain (src/chain/exec.rs):
| Mode |
Entry |
Per-item dispatch |
Completion detection |
| Sequential |
run_sequential |
dispatch_item → cmd::run::run / cmd::install::install_pms (inherited stdio) |
synchronous return of exit code |
| Parallel (live) |
run_parallel_streaming |
cmd::run::dispatch_task_piped (piped stdio + prefix muxer) |
poll child.try_wait() loop |
| Parallel (grouped) |
run_parallel_grouped |
same piped dispatch |
poll loop → flush_task_group replays buffered output |
Other relevant pieces:
- Single-task path (
dispatch_run without -s/-p) calls cmd::run directly — no chain wrapper today.
runner install <tasks> builds a synthetic sequential chain with an Install head (dispatch_install_chain in src/lib.rs).
- Output grouping: single-task and sequential chains use
task_group (src/cmd/mod.rs) for GHA ::group:: wrappers; grouped parallel uses flush_task_group with runner: <name> headers.
- No timing exists today —
Instant/Duration only appear in reader-drain grace logic and unrelated schema code.
Proposed implementation
1. Measurement
Wrap each per-item execution with std::time::Instant:
struct TaskTiming {
name: String,
started: Instant,
finished: Option<Instant>,
exit_code: i32,
}
- Sequential /
dispatch_item: let start = Instant::now(); let code = dispatch_item(...)?; emit_timing(name, start.elapsed(), code);
- Parallel: record
started at spawn; on try_wait() == Some(_) compute elapsed before/after reader join.
- Deno self-exec (
Dispatch::DenoSelfExec): still measurable around self_exec.run() — parallel chain currently bails on self-exec, sequential can hit it.
Use wall-clock elapsed (not CPU); format human-readably (e.g. 1.2s, 342ms, 1m 04s).
2. Emission site (by output mode)
| Mode |
Suggested placement |
Rationale |
| Sequential |
stderr after child exits, before next task |
inherited stdio is clean; mirrors → dispatch arrow on stderr |
| Parallel live |
one line per task on stderr after child reaped (prefix optional: [build] done in 1.2s (exit 0)) |
stdout/stderr already prefixed by muxer; timing is meta-output |
| Parallel grouped |
footer line inside/at end of each runner: <name> block in flush_task_group |
keeps block self-contained; GHA group title could include duration |
| Single-task (optional) |
stderr after cmd::run returns |
consistency when not chaining |
Respect --quiet: timing is diagnostic meta-output — either suppress under quiet (like the → arrow) or treat as orthogonal (needs product decision; I'd default to show unless --quiet since timing is the point).
3. CLI / config API (sketch)
Opt-in vs default-on is a product call. Suggested:
--timing / -T global flag
RUNNER_TIMING env (truthy, same pattern as RUNNER_EXPLAIN)
- Optional
[chain].timing = true in runner.toml
Could later add a chain-end summary table (--timing=verbose?) listing all tasks sorted by duration.
4. Code changes (expected touch points)
src/chain/exec.rs — primary logic in run_sequential, run_parallel_streaming, run_parallel_grouped / flush_task_group
src/cmd/mod.rs — small emit_task_timing(...) helper (stderr, colored/dimmed, GHA-aware)
src/resolver/types.rs + src/cli.rs + src/resolver/overrides.rs — wire timing flag through ResolutionOverrides (same pattern as quiet / explain)
src/lib.rs — optionally wrap single-task cmd::run if we want parity
tests/chain_integration.rs — fixture with sleep recipes; assert timing line appears and ordering
5. Edge cases
--kill-on-fail: killed siblings still emit timing for work completed before SIGKILL
--keep-going: emit timing for every item, including failures
- Install head in
runner install build test: time install separately from following tasks
- Parallel + install: still rejected (
install items cannot run in parallel chains) — no change
- Grouped vs live parallel: both paths must report (don't only implement one)
- GHA: duration in group footer avoids breaking
::group:: title parsing; optional ::notice for slow tasks is a follow-up
- JSON/machine output: out of scope for v1 unless
--json chain summary is planned
Example output (sketch)
Sequential:
→ justfile build
… build output …
· build finished in 1.8s (exit 0)
→ justfile test
…
· test finished in 4.2s (exit 1)
Parallel grouped:
runner: lint
… lint output …
finished in 0.9s (exit 0)
runner: test
…
finished in 6.1s (exit 0)
Testing
- Unit: duration formatting helper
- Integration:
tests/fixtures/chain-sequential (or new fixture) with a slow recipe (sleep 0.2); runner run -s a slow asserts two timing lines in order
- Parallel: assert each task gets a line even when completion order ≠ spawn order (grouped mode)
--quiet / flag-off → no timing lines
Non-goals (v1)
- Per-line streaming latency
- CPU/time profiling
- Persisting timing history
- Changing JSON schema for
doctor/why
References
- Chain executor:
src/chain/exec.rs
- Prefix muxer:
src/chain/mux.rs
- Chain types:
src/chain/mod.rs
- Existing chain integration tests:
tests/chain_integration.rs
Summary
When running task chains (
runner run -s …,runner run -p …, orrunner install [tasks]), users have no visibility into how long each task took. Add per-task duration reporting across sequential and parallel execution paths (and optionally single-task runs for consistency).Motivation
Chain mode is increasingly used for local dev and CI (
runner run -p lint test build). Failures and slow tasks are easier to diagnose when each item reports wall-clock duration on completion — similar toturbo run/nx run-manytask summaries, ortimeappended to each step.Current architecture (where to hook)
All chain dispatch funnels through
chain::exec::run_chain(src/chain/exec.rs):run_sequentialdispatch_item→cmd::run::run/cmd::install::install_pms(inherited stdio)run_parallel_streamingcmd::run::dispatch_task_piped(piped stdio + prefix muxer)child.try_wait()looprun_parallel_groupedflush_task_groupreplays buffered outputOther relevant pieces:
dispatch_runwithout-s/-p) callscmd::rundirectly — no chain wrapper today.runner install <tasks>builds a synthetic sequential chain with anInstallhead (dispatch_install_chaininsrc/lib.rs).task_group(src/cmd/mod.rs) for GHA::group::wrappers; grouped parallel usesflush_task_groupwithrunner: <name>headers.Instant/Durationonly appear in reader-drain grace logic and unrelated schema code.Proposed implementation
1. Measurement
Wrap each per-item execution with
std::time::Instant:dispatch_item:let start = Instant::now(); let code = dispatch_item(...)?; emit_timing(name, start.elapsed(), code);startedat spawn; ontry_wait() == Some(_)compute elapsed before/after reader join.Dispatch::DenoSelfExec): still measurable aroundself_exec.run()— parallel chain currently bails on self-exec, sequential can hit it.Use wall-clock elapsed (not CPU); format human-readably (e.g.
1.2s,342ms,1m 04s).2. Emission site (by output mode)
→dispatch arrow on stderr[build] done in 1.2s (exit 0))runner: <name>block inflush_task_groupcmd::runreturnsRespect
--quiet: timing is diagnostic meta-output — either suppress underquiet(like the→arrow) or treat as orthogonal (needs product decision; I'd default to show unless--quietsince timing is the point).3. CLI / config API (sketch)
Opt-in vs default-on is a product call. Suggested:
--timing/-Tglobal flagRUNNER_TIMINGenv (truthy, same pattern asRUNNER_EXPLAIN)[chain].timing = trueinrunner.tomlCould later add a chain-end summary table (
--timing=verbose?) listing all tasks sorted by duration.4. Code changes (expected touch points)
src/chain/exec.rs— primary logic inrun_sequential,run_parallel_streaming,run_parallel_grouped/flush_task_groupsrc/cmd/mod.rs— smallemit_task_timing(...)helper (stderr, colored/dimmed, GHA-aware)src/resolver/types.rs+src/cli.rs+src/resolver/overrides.rs— wiretimingflag throughResolutionOverrides(same pattern asquiet/explain)src/lib.rs— optionally wrap single-taskcmd::runif we want paritytests/chain_integration.rs— fixture withsleeprecipes; assert timing line appears and ordering5. Edge cases
--kill-on-fail: killed siblings still emit timing for work completed before SIGKILL--keep-going: emit timing for every item, including failuresrunner install build test: timeinstallseparately from following tasksinstall items cannot run in parallel chains) — no change::group::title parsing; optional::noticefor slow tasks is a follow-up--jsonchain summary is plannedExample output (sketch)
Sequential:
Parallel grouped:
Testing
tests/fixtures/chain-sequential(or new fixture) with aslowrecipe (sleep 0.2);runner run -s a slowasserts two timing lines in order--quiet/ flag-off → no timing linesNon-goals (v1)
doctor/whyReferences
src/chain/exec.rssrc/chain/mux.rssrc/chain/mod.rstests/chain_integration.rs