Skip to content

feat(dynamic-workflow): rerun finished workflows into a queryable, comparable lineage - #59

Open
modacker wants to merge 7 commits into
MiniMax-AI:mainfrom
modacker:community/rerun-lineage
Open

modacker wants to merge 7 commits into
MiniMax-AI:mainfrom
modacker:community/rerun-lineage

Conversation

@modacker

@modacker modacker commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

feat(dynamic-workflow): rerun finished workflows into a queryable, comparable lineage

Stacked on the trash/archive PR (community/run-lifecycle) — rebases cleanly once it merges.

Re-running a finished workflow now creates a new run from the original's script+input and never touches the original: before/after comparison of the source run's stored body, steps and events is byte-identical (pinned by test), and the event chain stays valid. Unlimited re-runs are supported by construction — every attempt is a full run with its own process and result.

Lineage

  • Each re-run carries rerunOf (parent), lineageRoot (root), rerunSeq (n-th member of the family) on its body; run.created events include the lineage fields.
  • GET /api/runs/:id/lineage returns the root plus every member ordered by seq with status, duration (read from the event ledger, so archived members are covered too), timestamps and a result excerpt (?results=1 for full results). Tombstoned members are flagged deleted, archived members archived.rotationId — deletion never breaks the family, and restoring returns it intact.
  • Synthesized requestIds (<root>#rerun-<n>, n = highest seq across live + tombstoned + archived members + 1) avoid the UNIQUE constraint and the idempotent-replay path; a collision retries with a numeric suffix and the squatter stays out of the family.

Safety and interactions

  • Re-run defaults reuseAcrossRuns to false: with cross-run reuse on, an identical context would adopt the original's succeeded nodes and the re-run would be a fake execution — useless for comparison. The flag can be set explicitly for cheap re-execution. Both behaviors are pinned (fresh dispatch with growing call counts vs. feat(dynamic-workflow): opt-in content-addressed cross-run reuse #49 adoption with zero new calls).
  • Sources in the trash or archive are refused with actionable pointers (restore first); workspace mismatch refused.
  • The re-run product is a normal pending_review run — approval, execution, fail-loud paths and budgets all apply unchanged.

Surfaces

  • MCP workflow_rerun (runId required; optional input override, explicit reuseAcrossRuns, name) with a strict schema; the stdio proxy reports WORKFLOW_SERVICE_UPGRADE_REQUIRED against an older background service instead of breaking it.
  • Studio: a lineage panel per run (member list with lineage tag / status / duration / result preview / deleted & archived flags) and a read-only side-by-side JSON comparison of any two members' results.

Checks and verification

checks/rerun-lineage.check.mjs (7 cases, real subprocesses / real store / real HTTP): source byte-identity across a re-run; three interleaved re-runs producing a 4-member ordered family with unique requestIds; default-no-reuse vs explicit reuse; trash → archive → restore round trip with the lineage intact and seq accounting skipping archived occupants; requestId collision suffix retry; lineage API shape; MCP schema and honest errors. Suites: npm test 130 → 137 green (macOS); Linux clean-tree gate green; Windows + CodeQL via the fork preview mirror. No new dependencies.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

moc added 6 commits September 21, 2026 20:31
Task package: sih-engine/sih/state/plan/run-lifecycle.md (PR1, first half).

- Tombstone soft delete on terminal runs (succeeded/failed/
  completed_with_gaps/cancelled/interrupted): run body gains
  deletedAt/deletedBy/purgeAfter plus an append-only run.deleted audit
  event; steps, events, result and the integrity ledger stay untouched.
- Query faces all filter tombstones: run list, snapshot (workflow_status,
  HTTP GET), cross-run reuse candidate scan, repair source scan, resume,
  and requestId idempotent replay (rejected with a recovery hint instead
  of resurrecting ghost data).
- Restore clears the tombstone and appends run.restored; the run
  reappears byte-identical. Repeat deletes are idempotent and never
  append a second event.
- Retention: trashRetentionDays setting (default 30, 0 = manual-only
  expiry) via the store settings face; changing it restamps tombstones
  already in trash so the displayed countdown stays truthful.
- Exposure: HTTP DELETE /api/runs/:id, POST /api/runs/:id/restore,
  GET /api/runs?trash=1, GET/POST /api/trash; MCP workflow_delete /
  workflow_restore (old daemons gated via the trashManagement feature
  flag); Studio trash view with restore button and retention countdown.
- checks/trash.check.mjs pins hide/restore round trips, every filtered
  face, idempotence, the reuse interaction, retention math and the
  HTTP/MCP surfaces; tool-count assertions move from 11 to 13.
Task package: sih-engine/sih/state/plan/run-lifecycle.md (PR1, second half).

- Rotation compaction exports due tombstones (purgeAfter <= now) into the
  sidecar <data-dir>/archive.db and physically deletes their live runs/steps
  rows; the events table never loses a row (program red line). The audit
  event archive.rotated {rotationId, runs, manifestHash, runCount, bytes} is
  appended in the same transaction as the deletes, anchoring the manifest on
  the MiniMax-AI#48 events hash chain.
- manifestHash is computed by one shared implementation
  (archiveManifestHash: canonical JSON over runs+steps rows in (runId, id)
  order) used by both rotation and verification, so the two hashes cannot
  drift; checks/archive-rotate.check.mjs recomputes it independently.
- Archive rows are keyed by (rotationId, runId) instead of a bare runs PK:
  a run that is restored, re-deleted and re-rotated writes a fresh copy and
  can never rewrite rows an earlier rotation's manifest still covers.
- Verification (verifyArchive) recomputes every rotation's manifest from
  the archived rows and compares it against both the rotations record and
  the chained event; chained-but-missing rotations and orphan archive rows
  fail closed. verifyIntegrity() gains an archive face alongside
  events/repair without touching their semantics.
- Restore from archive copies rows back (idempotent upserts), clears the
  tombstone and appends run.restored {origin:'archive', rotationId}; a
  requestId claimed by a newer live run blocks the restore loudly.
  workflow_delete on a rotated run reports the archive state truthfully.
- Triggers: CLI --rotate-archive (with --verify for the shared verify
  outlet; null verdicts from empty chains are not failures) and --restore
  <runId>, both forwarding to a live service instead of fighting the owner
  lock; service startup auto-rotates when tombstones exceed 500 or the
  runs table exceeds ~100MB (constants documented in store.mjs), skipped
  entirely when trashRetentionDays=0 (manual-only). HTTP face:
  POST /api/archive/rotate.
- checks/archive-rotate.check.mjs pins due-only rotation, the events-row
  red line, tamper detection (step body, forged rotations hash, deleted
  rotation record), archive restore and re-rotation verifiability, the
  501-vs-400 startup threshold against a real daemon, and the CLI faces
  with real exit codes.
…eage

Task package: sih-engine/sih/state/plan/run-lifecycle.md, PR2 (复跑谱系),
stacked on PR1's tombstone/restore/rotation commits.

A rerun starts a NEW pending_review run from the source's script+input and
never writes the source. The child carries rerunOf/lineageRoot/rerunSeq on
its body, so the family survives unlimited reruns, trash and archive
rotation (members are annotated, never removed).

Key decisions:

- requestId is synthesized as `<root>#rerun-<n>`; n counts live, tombstoned
  AND archived family members so a seq is never reused after rotation, with
  a numeric-suffix retry when the id is already claimed (150-char cap kept).
- reuseAcrossRuns defaults to false on reruns (explicit opt-in only): silent
  adoption would turn a rerun into a fake execution and poison comparison.
- Reruns pass through the existing pending_review/approve gate; the rerun
  action itself only creates the draft, it never auto-executes. Tombstoned
  sources are refused until restored; rerunning straight from the archive is
  deliberately not offered (restore first).
- GET /api/runs/:id/lineage resolves the family from any member id (live,
  tombstoned or archived), ordered root-first then by seq, with per-member
  status/duration/timestamps/result summary; ?results=1 adds full results
  for the read-only compare face. Durations come from the events ledger,
  which rotation never moves.
- MCP workflow_rerun (input override + reuseAcrossRuns flag) is gated behind
  a rerunLineage feature flag, mirroring the trash tools' old-daemon guard.
- Studio gains a lineage panel (member list with trash/archive flags) and a
  two-column read-only JSON result compare.
- New checks/rerun-lineage.check.mjs pins: source byte-identity across a
  rerun, family ordering over 3 reruns, default no-reuse vs explicit reuse,
  trash/rotation refusal with family integrity, requestId collision retry,
  HTTP lineage shape and the MCP schema.

Files: src/store.mjs src/engine.mjs src/http.mjs src/tools.mjs src/main.mjs
web/index.html web/app.source.mjs web/app.js web/i18n.mjs web/style.css
checks/rerun-lineage.check.mjs checks/package.check.mjs
checks/workspace-router.check.mjs test/package.test.mjs README.md dist/main.mjs
Fork preview run 35506090983 (first full windows-latest pass) failed check MiniMax-AI#9
"packaged MCP advertises reuseAcrossRuns and accepts it through the public
tool surface" with:

  EBUSY: resource busy or locked, rmdir 'C:\Users\RUNNER~1\AppData\Local\Temp\wf-cross-mcp-NCVTMk'

Every assertion passed; the failure came from the finally block.
StdioClientTransport.close() resolving does not mean the spawned dist/main.mjs
server process has exited, so the child still held the dataDir (the mkdtemp
tmp dir) handle when rm() ran, and win32 refuses to rmdir a busy directory.
Linux/darwin unlink open files, which is why all local runs were green.

- add a local rmWithRetry helper: retry rm on EBUSY/ENOTEMPTY/EPERM with
  exponential backoff (200ms base, x2, 5 retries, ~6s total budget), then
  rethrow as-is -- cleanup failures stay loud, we only give the OS time to
  release handles
- finally: wrap client/transport close in try/catch so a close error cannot
  skip rm; rethrow the close error after cleanup so it still fails the test

Only checks/cross-reuse-mcp.check.mjs is touched; no src/, no dist/, no other
checks, no new dependencies. Local verification: npm test exit=0, 126/126.
…-reuse MCP check

Round two of the same failure. Fork preview run 35506404071 (with 14a7fd5)
failed the same check MiniMax-AI#9 with the same code:

  EBUSY: resource busy or locked, rmdir '...\wf-cross-mcp-3VJDll'

The test ran 7945ms -- the full ~6.2s retry budget burned while the
directory stayed locked. Not a transient lock, so retrying cannot fix it.

Real root cause (src/main.mjs, stdio branch): when no existing service is
found, --stdio spawns a detached daemon -- spawn(process.execPath, args,
{cwd:workspace, detached:true, ...}); child.unref() -- with cwd set to the
workspace (the mkdtemp tmp dir), service.log and the database inside dataDir
(the same tmp dir). By design "the service, workers and dashboard outlive"
the chat transport, so client.close()/transport.close() only tear down the
stdio face and the daemon keeps running, holding its cwd handle plus
service.log/DB handles inside the tree. win32 refuses to rmdir a directory
tree a live process is sitting in; Linux/darwin unlink open files, which is
why local runs stay green. Layer two, from run 35506090983: transport.close()
resolving does not mean a child has fully exited, so handle release can lag
an instant even after the daemon is gone.

The codebase's own convention already stops the service first
(checks/fail-loud.check.mjs:80, checks/lifecycle.check.mjs:19): run
dist/main.mjs --stop-service --workspace <dir> --data-dir <dir> after closing
the client. Identity matches this test because its --settings file sets
workspace=dataDir=dir, the same endpoint.json the daemon registered under.

- finally: after client/transport close (still deferred-rethrow), run
  --stop-service wrapped in try/catch so a stop failure cannot block rm
- keep rmWithRetry as the release-lag fallback, still failing loud when
  exhausted
- rewrite the comment to the two-layer root cause with both run ids

Only checks/cross-reuse-mcp.check.mjs is touched; no src/, no dist/, no new
dependencies. Local verification: npm test exit=0, 126/126; ps confirms the
daemon spawned by the check is actually terminated by the stop-service step
(no stray wf-cross-mcp-* processes remain).
…r flags

Task package: sih-engine/sih/state/plan/run-lifecycle.md, PR2 (复跑谱系),
UI patch from the egolite real-panel interaction pass. Two bugs, one
render path, fixed as one decision: data refreshes, interaction state
survives.

Bug 1 (compare view wiped by polling): with two members selected, clicking
对比结果 shows the two-column diff (measured 1090x138 visible); ~6s later
the poll re-render hides it again (hidden:true, 0x0). Root cause:
renderLineage() ran unconditionally on every poll (refreshCurrent ->
renderRun -> renderLineage) and executed `#lineage-diff.hidden=true` plus
a full select rebuild each pass, resetting the user's diff visibility and
left/right selection every few seconds.

Bug 2 (stale deleted flag after trash restore): restoring rerun-1 left
its lineage card wearing the 已删除(回收站) flag across 8s+ and multiple
poll cycles even though the lineage API already returned deleted:null
(three members live, trash empty). Root cause: the flag renders from the
client's lineage cache, which was only refetched on selectRun and panel
toggle - neither happens on restore, so polls kept painting stale data
forever (not a DOM-residue issue: rows were rebuilt from stale input).

Fix:

- renderLineage() now splits data from interaction. Member cards (status,
  duration, trash/archive flags, preview) re-render on every pass from
  whatever the cache holds. The compare controls (selects, open diff) are
  rebuilt only when member identity changes, gated by a pure signature
  (language + ordered id:rerunSeq:name, web/lineage-model.mjs). Signature
  unchanged -> selects untouched, #lineage-diff.hidden untouched. Member
  added/removed/reordered/renamed or language switch -> full rebuild and
  the diff resets (member set changed, reset is legitimate). First render
  is unchanged (empty previous signature always rebuilds).
- refreshCurrent() refetches the lineage payload (background mode) when
  the panel is open, so member state can no longer go stale across polls;
  background errors keep the last good cache instead of blanking the open
  panel. Trash restore additionally triggers an immediate refetch.
- Regression nail: checks/lineage-panel.check.mjs pins the gate logic in
  node:test (poll-shaped refresh and flag clears never rebuild; identity/
  language/name changes always do). The DOM-level outcome (diff stays
  visible across 6s+ of polling) cannot be carried by node:test (no DOM);
  to be re-verified with the egolite interaction protocol: click compare
  -> still visible after 6s+ of polling.

Files: plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs
plugins/hetaoBackend/mcode-dynamic-workflows/web/lineage-model.mjs
plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js (rebuilt via
scripts/build-web.mjs)
plugins/hetaoBackend/mcode-dynamic-workflows/checks/lineage-panel.check.mjs
@modacker

Copy link
Copy Markdown
Contributor Author

Follow-up (55b8f48): the Studio lineage panel was additionally verified by driving the real dashboard end-to-end against an isolated service (create → approve → rerun ×2 → delete → trash dialog → restore → compare). That interactive pass caught one real UI defect the API-level checks could not see: the periodic poll re-rendered the lineage section unconditionally, which (a) wiped the side-by-side comparison view seconds after the user opened it (measured: visible 1090×138 right after the click, collapsed to 0×0 within one poll cycle) and (b) left a stale "deleted" flag rendering after a trash restore while the API already reported the member as live.

The render path now splits data from interaction state: member cards (status / duration / deleted / archived flags / result previews) refresh on every poll, while the compare controls (selections, diff visibility) are only rebuilt when the lineage's identity signature changes (member ids/seqs/names/language) — with a toggle handler refetching on panel expansion and an immediate refetch after a trash restore. The signature logic is extracted as pure functions and pinned by checks/lineage-panel.check.mjs (5 tests); the DOM-level outcome (comparison surviving 9 s of polls, flags clearing on expand) was re-verified interactively. Suite 137 → 142 green; Linux clean-tree gate and the Windows/CodeQL fork preview run are green at this head.

@hetaoBackend hetaoBackend left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes for exact current head 55b8f48e341648d73692721d76a159c55fbb8921.

Blocking archive-integrity issue:

  • plugins/hetaoBackend/mcode-dynamic-workflows/src/store.mjs:205-226 verifies rotation manifests, but restoreArchived() at :239-255 selects/parses the latest archived run and writes it into the live database without verifying that rotation against its chained archive.rotated event first. A valid-JSON but modified archived run/step can therefore be restored as trusted live data even though verifyArchive() would report tampering. Add a rotation-scoped verification before any restore write and a regression proving tampered restore fails closed without live rows or a run.restored event.

Also correct the README tool-count mismatch (README.md:5 says 13 while README.md:85 lists 14). The exact-head Ubuntu, Windows, package and CodeQL checks are green, but [code]smith is skipped and is not evidence.

PR MiniMax-AI#59 re-review: restoreArchived() selected/parsed the latest archived
run and wrote it into the live database without verifying that rotation
against its chained archive.rotated event first, so a valid-JSON but
modified archived run/step could be restored as trusted live data even
though verifyArchive() would report the tampering.

Verify-then-write: extract the per-rotation check (manifest recompute
plus chained archive.rotated event cross-check, same archiveManifestHash
face) into verifyRotation(), shared by verifyArchive() and by a
fail-closed gate at the top of restoreArchived(). A failed verdict
throws before any live write: no runs/steps rows, no run.restored
event, and the error names the rotation id plus the reasons.

Regression (archive-rotate.check.mjs): tampering an archived step or
run body makes restore refuse with the rotation id while the live
library keeps zero rows and zero run.restored events; healing the
archive lets the same restore pass again (round-trip). Existing
healthy-restore coverage unchanged and green.

Also correct the README tool-count mismatch (line 5 said 13 tools; the
TOOLS array and README's own MCP tool list both have 14).

- src/store.mjs: verifyRotation() shared check + restore gate
- checks/archive-rotate.check.mjs: tampered-restore fail-closed + round-trip
- dist/main.mjs: rebuilt bundle
- README.md: 13 -> 14 tools
@modacker

Copy link
Copy Markdown
Contributor Author

Fixed in b1c797c — restore is now verify-before-write:

  • A shared verifyRotation(rotationId) performs rotation-scoped verification (recomputes the manifest hash from the archived rows with the same archiveManifestHash implementation — no second hash — and cross-checks the rotations record against the chained archive.rotated event: exactly one event, matching manifestHash and runCount, archived-row count reconciled). verifyArchive() now delegates per-rotation to the same function, so verify and restore gate share one implementation.
  • restoreArchived() gates on it after locating the archived rows but before the requestId-conflict check and any live write: an unverified rotation throws with the rotation id and the failure reasons — the transaction never opens, so there are zero live rows and zero run.restored events.
  • Regression added: tampering an archived step body (and, in a second round, a run body) makes restore fail closed — no live run/steps rows, no run.restored event, events table unchanged — and the same restore passes again once the archive is healed, including across a subsequent healthy rotation (both rotations verify). The pre-existing healthy-restore cases (provenance, re-rotation, idempotence, requestId conflict) are untouched and green.
  • README tool count corrected to 14 (matches the src/tools.mjs TOOLS array and the :85 list).

Suites: 137 → 143 green; exact-head Linux clean-tree gate green; Windows + CodeQL via the fork preview mirror (run 35694871485).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants