Skip to content

fix(#484): terminal completion persistence + child-contract invalidation - #519

Merged
richard-devbot merged 3 commits into
mainfrom
fix-484-terminal-completion-child-contracts
Jul 31, 2026
Merged

fix(#484): terminal completion persistence + child-contract invalidation#519
richard-devbot merged 3 commits into
mainfrom
fix-484-terminal-completion-child-contracts

Conversation

@richard-devbot

Copy link
Copy Markdown
Owner

Summary

Closes the two confirmed, verifiable bugs from #484's audit finding; investigates and deliberately does NOT implement a third change that would have regressed an existing, deliberate test.

  • Terminal completion was never actually persisted. completed_at was written nowhere in the codebase — only read by deriveRunStatus/statusFromEntry, which key their "done" classification on it. The only writer of status: "DONE" was sdlc_status's handler, fired opportunistically. A finished run nobody polled afterward stayed IN_PROGRESS forever, eventually classified "stalled". markRunCompleted() now stamps both atomically at the actual moment of completion (end of sdlc_validate's success path), idempotently.
  • Child-contract invalidation: runSignature() never covered tasks/<taskId>/{builder,validation,prompt} files. A retry overwrites builder.json in place, which doesn't bump the parent directory's mtime on POSIX — a cached "unchanged" index entry could keep serving a stale verdict. childContractsSignature() closes this.
  • Investigated, deliberately reverted: attempted to make the rollup-index's general pipeline rollup also rebuild on pipelineStateEventsBehind (matching attachPipelineRollups' repair branch, per a literal reading of "the rollup index can preattach a pipeline rollup and bypass that fallback"). This regressed tests/dashboard-command-pages.test.js's pre-existing, deliberate test pinning the OPPOSITE contract — the general rollup must show honest staleness, not silently rebuild on every 3s poll for every active run. Caught by this PR's own verification pass; reverted before merge. Full reasoning in computeRunPipelineRollup's doc comment and docs/HARNESS.md.

Deferred (documented in docs/HARNESS.md): the full state_generation/outbox subsystem, multi-instance WebSocket delivery + reconnect-cursor reconciliation, and a degraded/error surface at the generation layer. None of these primitives exist anywhere in the codebase today (confirmed by direct inspection before scoping) — building them is a separate, much larger subsystem than this slice.

Test plan

  • npm run typecheck — clean
  • npm test — 1822/1822 passing, including 3 new mutation-checked tests in tests/extension-terminal-completion-484.test.js (proven to fail without the fix) and 1 new mutation-checked test in tests/dashboard-rollup-index.test.js
  • npm run lint — 0 errors (4 pre-existing unrelated warnings)
  • node scripts/security-audit.mjs — clean (1 pre-existing tolerated bundled advisory)
  • npm run validate — 196/196 agents pass
  • git diff --check — clean, no package-lock.json drift

🤖 Generated with Claude Code

…ion for the Business Hub

completed_at was never written anywhere in the codebase — only ever read by
deriveRunStatus/statusFromEntry, which key their "done" classification on it,
not on manifest.status. The only writer of status: "DONE" was sdlc_status's
handler, fired opportunistically, so a finished run that nobody polled
afterward stayed IN_PROGRESS forever and eventually classified "stalled".

markRunCompleted() now stamps status: "DONE" and completed_at atomically
(one read-modify-write under the manifest's own lock) at the actual moment
of completion — the end of sdlc_validate's success path — and is idempotent,
so sdlc_status's original opportunistic check remains safe as a defensive
catch-up without ever moving an already-terminal run's timestamp.

runSignature() covered manifest/events/tasks/approvals/evidence + the run
dir's own mtime, but never tasks/<taskId>/{builder,validation,prompt} files —
a retry overwrites builder.json in place, which doesn't bump the parent
directory's mtime on POSIX filesystems, so a cached "unchanged" entry could
keep serving a stale verdict. childContractsSignature() closes this.

Investigated but deliberately NOT changed: attempted to also make the
rollup-index's general pipeline rollup rebuild on pipelineStateEventsBehind
(matching attachPipelineRollups' repair branch) — this regressed an existing,
deliberate test pinning the opposite contract (the general rollup must show
honest staleness, not rebuild on every 3s poll for every active run). Caught
by this PR's own verification pass and reverted; reasoning recorded in
computeRunPipelineRollup's doc comment and docs/HARNESS.md.

Deferred (documented in docs/HARNESS.md): the full state_generation/outbox
subsystem, multi-instance WebSocket delivery and reconnect-cursor
reconciliation, and a degraded/error surface at the generation layer — none
of these primitives exist anywhere in the codebase today; building them is a
separate, much larger subsystem than this slice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@strix-security

strix-security Bot commented Jul 31, 2026

Copy link
Copy Markdown

Strix Security Review

No security issues found.

Updated for 6ffcc3e.


Reviewed by Strix
Re-run review · Configure security review settings

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@richard-devbot, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: be746ebd-608c-4e0a-8125-ee22ed18bf53

📥 Commits

Reviewing files that changed from the base of the PR and between 8e5ef36 and a05c8c3.

📒 Files selected for processing (6)
  • docs/HARNESS.md
  • src/integrations/pi/rstack-sdlc.ts
  • src/observability/dashboard/state/rollup-index.js
  • tests/dashboard-e2e-96.test.js
  • tests/dashboard-rollup-index.test.js
  • tests/extension-terminal-completion-484.test.js

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix terminal completion persistence + child-contract invalidation (#484)

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Persist terminal completion by stamping manifest status=DONE and completed_at atomically.
• Invalidate cached rollup entries when per-task child contracts change in-place.
• Add regression tests and document why rollup “events_behind” rebuild was intentionally avoided.
Diagram

graph TD
  A(["SDLC tools\n(sdlc_validate / sdlc_status)"]) --> B["rstack-sdlc.ts\n(markRunCompleted)"] --> C["manifest.json\n(status + completed_at)"]
  A --> D["tasks.json\n(task list/status)"]
  E(["Dashboard state\n(buildFullState)"]) --> F["rollup-index.js\n(runSignature)"] --> G[("Rollup index cache")]
  F --> C --> G
  F --> D --> G
  F --> H["tasks/<id>/*\n(builder/validation/prompt)"] --> G
  subgraph Legend
    direction LR
    _svc(["Service / module"]) ~~~ _file["File"] ~~~ _cache[("Cache / index")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Run-level generation counter + outbox cursor (design from issue)
  • ➕ More robust invalidation than file mtimes (no reliance on filesystem semantics).
  • ➕ Naturally supports multi-instance event delivery and reconnect reconciliation.
  • ➖ Large new subsystem (manifest transition protocol, outbox persistence, cursor semantics).
  • ➖ Higher risk and scope than closing the two confirmed bugs.
2. Content hashing for child contracts (instead of stat/mtime)
  • ➕ Detects changes even when mtimes are coarse or manipulated.
  • ➕ Avoids directory mtime pitfalls entirely.
  • ➖ Higher IO/CPU cost on frequent polling (hashing many files repeatedly).
  • ➖ Still needs careful bounding/short-circuiting for active runs.
3. Centralize completion stamping solely in sdlc_status
  • ➕ Single place to enforce completion invariants.
  • ➕ Avoids additional work in validate success path.
  • ➖ Reintroduces the core bug: completion depends on a later poll.
  • ➖ Makes completion latency and correctness dependent on observer behavior.

Recommendation: Keep the PR’s approach: stamp completion at the true completion point (sdlc_validate success path) with an idempotent, lock-guarded write, and expand the rollup-index signature to include per-task child-contract stats. The larger generation/outbox architecture is a valid long-term direction but is materially bigger than this PR’s scoped, verifiable fixes; the added tests make the chosen minimal fix safe and regression-resistant.

Files changed (5) +397 / -11

Bug fix (2) +150 / -11
rstack-sdlc.tsPersist terminal completion (status + completed_at) atomically and idempotently +81/-6

Persist terminal completion (status + completed_at) atomically and idempotently

• Introduces manifest.completed_at and adds markRunCompleted() which lock-guards a read-modify-write to stamp status=DONE and completed_at exactly once. Wires completion stamping into sdlc_validate’s PASS path (true completion moment) and updates sdlc_status to use the same eligibility + idempotent completion logic as a defensive catch-up without clobbering timestamps.

src/integrations/pi/rstack-sdlc.ts

rollup-index.jsInclude per-task child contracts in runSignature for cache invalidation +69/-5

Include per-task child contracts in runSignature for cache invalidation

• Adds childContractsSignature() to stat builder.json/validation.json/prompt.md under each task output_dir listed in tasks.json, and incorporates it into the run signature used for rollup-index caching. Expands documentation around pipeline rollup freshness to explain why the general rollup must surface events_behind rather than rebuilding pipeline-state on every poll.

src/observability/dashboard/state/rollup-index.js

Tests (2) +169 / -0
dashboard-rollup-index.test.jsRegression test for child-contract invalidation on in-place builder.json rewrite +43/-0

Regression test for child-contract invalidation on in-place builder.json rewrite

• Adds a test that reproduces a stalled, index-served run remaining cached when tasks/<id>/builder.json is rewritten in place, and asserts the new signature forces a full re-parse. Validates behavior against POSIX directory mtime semantics (no entry mtime bump on in-place writes).

tests/dashboard-rollup-index.test.js

extension-terminal-completion-484.test.jsRegression tests for completion persistence and idempotent catch-up +126/-0

Regression tests for completion persistence and idempotent catch-up

• Adds three tests proving that the last-task PASS via sdlc_validate persists status=DONE and completed_at immediately, that a later sdlc_status call does not change completed_at, and that sdlc_status can catch up older runs that finished before validate-time stamping existed.

tests/extension-terminal-completion-484.test.js

Documentation (1) +78 / -0
HARNESS.mdDocument #484 findings, fixes, and intentionally deferred scope +78/-0

Document #484 findings, fixes, and intentionally deferred scope

• Adds a detailed postmortem-style section explaining why completed_at was never persisted, why child-contract mtimes must be part of rollup invalidation, and why rebuilding rollups on events_behind was deliberately not implemented. Records explicitly deferred larger-system work (generation/outbox, multi-instance WS delivery, degraded surfaces).

docs/HARNESS.md

@qodo-code-review

qodo-code-review Bot commented Jul 31, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (5)

Context used
✅ Compliance rules (platform): 300 rules
✅ Skills: 17 invoked
  code-review-pr
  claude-api
  documentation-writing
  pptx
  docx
  performance-monitoring
  security-compliance
  cso
  plan-eng-review
  design-review
  prompt-engineering
  mcp-builder
  qa-testing
  code-patterns
  xlsx
  security-owasp
  testing-qa

Grey Divider


Action required

1. Child signature wrong path ✓ Resolved 🐞 Bug ≡ Correctness
Description
childContractsSignature() joins runDir with task.output_dir, but sdlc_plan writes output_dir as a
project-root-relative path like ".rstack/runs/<runId>/tasks/<taskId>". This makes the rollup
signature stat non-existent nested paths, so in-place task contract rewrites won’t change the
signature and cached index entries can remain stale.
Code

src/observability/dashboard/state/rollup-index.js[R126-139]

+async function childContractsSignature(io, runDir) {
+  let taskIds;
+  try {
+    const raw = await io.readFile(join(runDir, 'tasks.json'), 'utf8');
+    const parsed = JSON.parse(raw);
+    taskIds = Array.isArray(parsed?.tasks) ? parsed.tasks.map((t) => t?.output_dir).filter(Boolean) : [];
+  } catch {
+    return {};
+  }
+  const sig = {};
+  await Promise.all(taskIds.map(async (outputDir) => {
+    const perFile = await Promise.all(CHILD_CONTRACT_FILES.map((name) => fileSig(io, join(runDir, outputDir, name))));
+    sig[outputDir] = perFile;
+  }));
Relevance

●●● Strong

Likely treated as a real correctness bug in signature computation; team usually fixes such path/IO
mistakes.

PR-#509

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The rollup-index signature code uses output_dir values read from tasks.json to build child
contract paths under runDir. But the producer (sdlc_plan) sets output_dir to a
projectRoot-relative .rstack/runs/... path, so the joined path points to a nested
.rstack/runs/... directory under the run directory that does not contain the contracts. The
dashboard’s actual full parse reads contracts from <runDir>/tasks/<taskId>, confirming the
intended location.

src/observability/dashboard/state/rollup-index.js[124-163]
src/integrations/pi/rstack-sdlc.ts[2296-2351]
src/observability/dashboard/state/runs.js[74-83]
tests/dashboard-rollup-index.test.js[320-335]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`childContractsSignature()` currently reads `tasks.json` and uses each task's `output_dir` to build file paths as `join(runDir, outputDir, name)`. For runs produced by `sdlc_plan`, `output_dir` is **project-root-relative** (e.g. `.rstack/runs/<runId>/tasks/<taskId>`), so joining it under `runDir` produces a **wrong nested path** and the signature stays unchanged even when the real contract files change.

## Issue Context
- `sdlc_plan` writes `output_dir` as `.rstack/runs/${run_id}/tasks/${stage.id}` (relative to `projectRoot`).
- The actual contract files live under `<projectRoot>/.rstack/runs/<runId>/tasks/<taskId>/*`.
- The new test uses `output_dir: 'tasks/07-code'` (runDir-relative), which masks the production mismatch.

## Fix Focus Areas
- src/observability/dashboard/state/rollup-index.js[124-141]
- src/integrations/pi/rstack-sdlc.ts[2296-2351]
- tests/dashboard-rollup-index.test.js[320-357]

## Recommended fix
1. Parse `tasks.json` and prefer task ids (`t.id`) rather than `output_dir` for signature paths.
2. Build signatures using the known canonical location:
  - `join(runDir, 'tasks', taskId, 'builder.json')`
  - `join(runDir, 'tasks', taskId, 'validation.json')`
  - `join(runDir, 'tasks', taskId, 'prompt.md')`
3. Update/add a regression test case where `tasks.json` uses the real `output_dir` format produced by `sdlc_plan` (i.e. `.rstack/runs/<runId>/tasks/<id>`), and verify the signature invalidates when `<runDir>/tasks/<id>/builder.json` is rewritten in place.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Completion-error path untested 📜 Skill insight ▣ Testability
Description
A new try/catch swallows failures when stamping run completion, but there is no test that forces
this error branch to run and asserts the intended behavior.
Code

src/integrations/pi/rstack-sdlc.ts[R3605-3613]

+      if (status === "PASS" && !manifest.completed_at) {
+        try {
+          const freshTasks = JSON.parse(await readFile(tasksPath, "utf8")).tasks;
+          if (await isRunEligibleForCompletion(projectRoot, manifest, freshTasks)) {
+            await markRunCompleted(projectRoot, manifest.run_id);
+          }
+        } catch (completionError) {
+          console.error("Failed to mark run completed:", completionError);
+        }
Relevance

●●● Strong

They’ve accepted adding tests that force newly added error-handling branches to execute.

PR-#518

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400010 requires tests that trigger newly-added error handling. The PR adds a
catch (completionError) branch that logs Failed to mark run completed, and there is no
corresponding test that induces this failure case (e.g., corrupt tasks.json or make
markRunCompleted throw) and asserts the expected non-failing behavior.

src/integrations/pi/rstack-sdlc.ts[3605-3613]
Skill: qa-testing

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new error-handling path for completion stamping (`catch (completionError) { console.error(...) }`) has no test that triggers the failure path and verifies behavior.

## Issue Context
If `readFile(tasksPath)` / JSON parsing / `markRunCompleted()` fails, the code intentionally logs and continues. The compliance requirement asks for tests that trigger newly-added error-handling branches.

## Fix Focus Areas
- src/integrations/pi/rstack-sdlc.ts[3605-3613]
- tests/extension-terminal-completion-484.test.js[68-110]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. isRunEligibleForCompletion uses any ✓ Resolved 📜 Skill insight ✧ Quality
Description
New TypeScript code introduces any in isRunEligibleForCompletion() (both the tasks parameter
and the callback variable), weakening type safety and allowing invalid task shapes to slip through
compile-time checks. This violates the requirement to avoid any in TypeScript MCP server code.
Code

src/integrations/pi/rstack-sdlc.ts[R785-786]

+async function isRunEligibleForCompletion(projectRoot: string, manifest: RunManifest, tasks: any[]): Promise<boolean> {
+  if (!tasks.length || tasks.some((t: any) => t.status !== "PASS")) return false;
Relevance

●●● Strong

New any in rstack-sdlc.ts has been repeatedly called out and rejected; team tends to remove it.

PR-#489
PR-#503
PR-#506

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance IDs 1400551 and 1399957 disallow use of the any type in TypeScript; the newly-added
helper isRunEligibleForCompletion explicitly declares tasks as any[] and iterates with `(t:
any), directly demonstrating the prohibited any` usage in the function signature and predicate.

src/integrations/pi/rstack-sdlc.ts[785-786]
Skill: mcp-builder
Skill: code-patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`isRunEligibleForCompletion()` introduces `any` types (`tasks: any[]` and `(t: any)`), which defeats TypeScript safety, makes it easier for invalid task shapes to slip through unchecked, and violates the no-`any` requirement for this TypeScript code.

## Issue Context
This helper is part of completion eligibility logic used to decide whether a run can be marked completed; relying on `any` removes compiler enforcement around the expected task fields (at least `status` and its allowed values), so incorrect task shapes (e.g., missing `status` or wrong status values) could silently bypass eligibility checks.

## Fix Focus Areas
- src/integrations/pi/rstack-sdlc.ts[785-790]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Temp dirs not cleaned ✓ Resolved 📜 Skill insight ▣ Testability
Description
The new terminal-completion tests create a temporary projectRoot directory per test run but never
delete it in after/afterEach, leaving filesystem state behind that can accumulate and cause
flaky behavior on constrained CI. The file also reuses a single mutable mockPi across tests,
reducing isolation and increasing flake risk if tests/files run concurrently.
Code

tests/extension-terminal-completion-484.test.js[R39-52]

+async function setupExpressRun(t) {
+  const projectRoot = mkdtempSync(join(tmpdir(), 'rstack-484-terminal-'));
+  t.after(() => { process.env.RSTACK_PROJECT_ROOT = undefined; });
+  process.env.RSTACK_PROJECT_ROOT = projectRoot;
+  writeFileSync(join(projectRoot, 'src-change.js'), 'export const ok = 1;\n');
+  extension(mockPi);
+  // express mode skips the release-approval gate (plan/requirements/
+  // architecture/release-readiness sign-off) so this test isolates the
+  // terminal-completion fix from an unrelated approval-gate dependency.
+  const start = await mockPi.tools.sdlc_start.execute('1', { goal: 'Terminal completion wiring', mode: 'express' });
+  const runId = start.details.run_id;
+  await mockPi.tools.sdlc_plan.execute('2', { run_id: runId });
+  return { projectRoot, runId };
+}
Relevance

●●● Strong

Temp-dir cleanup in tests has been accepted as required hygiene (try/finally or hooks).

PR-#167

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400495 requires tests that create data (files/directories) to clean it up via
hooks, but the helper in tests/extension-terminal-completion-484.test.js creates a temp directory
(via mkdtempSync(...)) and only clears/reset an env var in t.after() without removing the
directory. This contrasts with other extension tests that explicitly delete their mkdtempSync
roots (often via rmSync in finally or equivalent cleanup), demonstrating the expected hygiene
pattern and confirming the new test file is missing that cleanup.

tests/extension-terminal-completion-484.test.js[39-52]
tests/extension-approval-digest-407.test.js[28-83]
Skill: testing-qa

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The terminal-completion tests create temporary directories with `mkdtempSync(...)` but never delete them (only clearing `RSTACK_PROJECT_ROOT`), violating the cleanup requirement for created test data and leaving temp directories behind after the suite runs. The tests also share a single mutable `mockPi` across tests, which reduces test isolation and can contribute to flakes.

## Issue Context
Leaving temp directories behind can bloat CI disks over time and introduce flaky behavior, especially on constrained or long-running CI environments. Other extension tests typically use a strict cleanup pattern (restore environment variables and delete temp directories, often via `try/finally` or test hooks) to avoid leaking filesystem state across tests/runs; this file should follow the same approach.

## Fix Focus Areas
- tests/extension-terminal-completion-484.test.js[20-52]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (2)
5. setTimeout uses magic number ✓ Resolved 📜 Skill insight ⚙ Maintainability
Description
The test introduces a literal 5 millisecond delay in setTimeout, rather than using a named
constant. This reduces clarity about why the delay exists and makes future tuning harder.
Code

tests/extension-terminal-completion-484.test.js[106]

+  await new Promise((r) => setTimeout(r, 5));
Relevance

●●● Strong

Team previously accepted replacing hardcoded timeout numbers in tests with named constants.

PR-#509

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400588 requires numeric literals used for timeouts to be replaced with named
constants; the test uses setTimeout(..., 5) directly.

tests/extension-terminal-completion-484.test.js[104-107]
Skill: code-patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A `setTimeout(r, 5)` literal is used in the test; the compliance rule requires replacing magic numbers with named constants.

## Issue Context
This delay encodes a test timing assumption; giving it a name documents intent (e.g., ensuring a later timestamp would differ if clobbered).

## Fix Focus Areas
- tests/extension-terminal-completion-484.test.js[104-107]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Uncontained signature path input ✓ Resolved 🐞 Bug ⛨ Security
Description
childContractsSignature() uses task.output_dir from tasks.json as a path component without
validating it stays inside the run directory. A corrupted/malicious tasks.json with traversal
segments (e.g. "../../…") can make the dashboard process attempt stats outside the run dir,
increasing attack surface and potentially exposing filesystem metadata via downstream
state/signature effects.
Code

src/observability/dashboard/state/rollup-index.js[R129-139]

+    const raw = await io.readFile(join(runDir, 'tasks.json'), 'utf8');
+    const parsed = JSON.parse(raw);
+    taskIds = Array.isArray(parsed?.tasks) ? parsed.tasks.map((t) => t?.output_dir).filter(Boolean) : [];
+  } catch {
+    return {};
+  }
+  const sig = {};
+  await Promise.all(taskIds.map(async (outputDir) => {
+    const perFile = await Promise.all(CHILD_CONTRACT_FILES.map((name) => fileSig(io, join(runDir, outputDir, name))));
+    sig[outputDir] = perFile;
+  }));
Relevance

●●● Strong

Repo has a strong precedent for traversal hardening and regression tests around path-bypass
attempts.

PR-#399

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new code parses tasks.json and uses output_dir directly to construct filesystem paths for
stat() calls, with no normalization/containment guard. That makes the code sensitive to traversal
segments in tasks.json.

src/observability/dashboard/state/rollup-index.js[126-140]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`childContractsSignature()` treats `output_dir` from `tasks.json` as trusted and feeds it into `join(runDir, outputDir, name)`. If `tasks.json` is corrupted/tampered, `output_dir` can include traversal (e.g. `../../outside`) causing `fileSig()` to `stat()` unintended paths.

## Issue Context
Even if run state is usually trusted, the dashboard should be resilient to malformed state and must not follow untrusted relative paths outside the run directory.

## Fix Focus Areas
- src/observability/dashboard/state/rollup-index.js[124-141]

## Recommended fix
- Prefer building child-contract paths from canonical, known-safe components (e.g. `join(runDir,'tasks',taskId,...)`) rather than `output_dir`.
- If `output_dir` is still used for any reason, add containment validation:
 1. Compute `const candidate = resolve(runDir, outputDir, name)`
 2. Require `candidate.startsWith(resolve(runDir) + sep)`
 3. Skip / treat as missing if containment fails.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

7. Duplicate #484 comment block ✓ Resolved 📜 Skill insight ⚙ Maintainability
Description
The sdlc_status section contains two consecutive comment blocks that repeat the same idea about
the defensive catch-up behavior. This adds noise and violates the rule against redundant comments.
Code

src/integrations/pi/rstack-sdlc.ts[R3709-3718]

+      // #484: defensive catch-up for a run that finished before this check
+      // existed at sdlc_validate time (or completed between validate calls
+      // for another reason) — markRunCompleted is idempotent, so this is
+      // safe alongside the primary stamp at the actual completion moment.
+      let currentManifest = manifest;
+      // #484: defensive catch-up for a run that finished before this check
+      // existed at sdlc_validate time (or whose completion is only observed
+      // on a later poll) — markRunCompleted is idempotent, so calling it here
+      // too is safe alongside the primary stamp at the actual completion
+      // moment inside sdlc_validate.
Relevance

●●● Strong

Redundant adjacent comment cleanup is low-risk maintainability; similar small refactors get
accepted.

PR-#489

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1399213 disallows comments that merely restate or duplicate nearby content. The
comments at lines 3709-3718 repeat the same explanation twice back-to-back.

src/integrations/pi/rstack-sdlc.ts[3709-3718]
Skill: documentation-writing

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Two adjacent comment blocks restate the same rationale for the defensive `markRunCompleted()` call in `sdlc_status`.

## Issue Context
Keeping just one clear comment improves readability without losing rationale.

## Fix Focus Areas
- src/integrations/pi/rstack-sdlc.ts[3709-3718]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Non-descriptive t2 variable ✓ Resolved 📜 Skill insight ⚙ Maintainability
Description
The new test uses t2 as a loop variable name, which is not descriptive and makes the code harder
to read. This violates the requirement for descriptive variable names.
Code

tests/extension-terminal-completion-484.test.js[116]

+  for (const t2 of state.tasks) t2.status = 'PASS';
Relevance

●● Moderate

Naming-nit renames in tests are sometimes rejected; no close precedent for short loop vars
specifically.

PR-#511

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1399635 requires descriptive variable names and flags ambiguous short names like
t2. The loop for (const t2 of state.tasks) introduces a non-descriptive identifier in changed
code.

tests/extension-terminal-completion-484.test.js[115-117]
Skill: code-patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The loop variable `t2` does not describe its purpose.

## Issue Context
This is a tasks loop; the variable name should reflect that (e.g., `task`, `runTask`, `taskEntry`).

## Fix Focus Areas
- tests/extension-terminal-completion-484.test.js[115-117]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. childContractsSignature mutates sig 📜 Skill insight ≡ Correctness
Description
childContractsSignature() builds sig via direct property assignment (sig[outputDir] = ...),
violating the immutable-update requirement. This can lead to accidental shared-state patterns as the
function evolves.
Code

src/observability/dashboard/state/rollup-index.js[R135-139]

+  const sig = {};
+  await Promise.all(taskIds.map(async (outputDir) => {
+    const perFile = await Promise.all(CHILD_CONTRACT_FILES.map((name) => fileSig(io, join(runDir, outputDir, name))));
+    sig[outputDir] = perFile;
+  }));
Relevance

● Weak

Past “avoid object mutation” suggestions were rejected; local accumulator mutation isn’t treated as
a violation.

PR-#503

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1399759 prohibits direct mutation assignments. The new signature builder assigns
into an object (sig[outputDir] = perFile) instead of producing a new object immutably.

src/observability/dashboard/state/rollup-index.js[135-140]
Skill: code-patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new `childContractsSignature()` constructs `sig` by mutating an object (`sig[outputDir] = perFile`). Compliance requires immutable update patterns.

## Issue Context
This can be rewritten to return a new object from `Promise.all(...)` results (e.g., via `Object.fromEntries`).

## Fix Focus Areas
- src/observability/dashboard/state/rollup-index.js[135-140]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
10. markRunCompleted mutates fresh 📜 Skill insight ≡ Correctness
Description
markRunCompleted() directly mutates the fresh manifest object via property assignment, violating
the requirement to use immutable update patterns. This increases the risk of unintended side effects
and makes state changes harder to reason about.
Code

src/integrations/pi/rstack-sdlc.ts[R775-776]

+    fresh.status = "DONE";
+    fresh.completed_at = timestamp();
Relevance

● Weak

Similar “make mutations immutable” refactors were rejected as churn; in-place writes under lock
considered acceptable.

PR-#503

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1399759 forbids direct mutation like obj.prop = value. In markRunCompleted(),
the code assigns fresh.status and fresh.completed_at directly on an existing object.

src/integrations/pi/rstack-sdlc.ts[770-777]
Skill: code-patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new `markRunCompleted()` implementation mutates an existing object (`fresh.status = ...`, `fresh.completed_at = ...`). Compliance requires immutable update patterns.

## Issue Context
This occurs inside the manifest lock, so it can be safely refactored to create a new manifest object and pass that to `writeManifest()`.

## Fix Focus Areas
- src/integrations/pi/rstack-sdlc.ts[770-777]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. Regression tests missing attribution 📜 Skill insight ⚙ Maintainability
Description
New regression tests for #484 do not include the required attribution block (issue ID, what broke,
date found, QA report path).
Code

tests/dashboard-rollup-index.test.js[R320-321]

+test('#484 an in-place tasks/<id>/builder.json rewrite invalidates a stalled run entry', async () => {
+  const projectRoot = mkdtempSync(join(tmpdir(), 'rstack-rollup-childcontract-inval-'));
Relevance

● Weak

Regression-test attribution-metadata requests have been consistently rejected in recent PRs.

PR-#508
PR-#509
PR-#511

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400067 requires regression tests to include an attribution comment with issue ID,
description, date found, and QA report path. The newly-added #484 tests do not include all required
fields (and one has no attribution block at all).

tests/dashboard-rollup-index.test.js[320-321]
tests/extension-terminal-completion-484.test.js[1-10]
Skill: qa-testing

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New regression tests added for #484 are missing the required attribution comment fields: issue ID, description of what broke, date found, and QA report path.

## Issue Context
This is required for auditability/traceability of regressions.

## Fix Focus Areas
- tests/dashboard-rollup-index.test.js[320-321]
- tests/extension-terminal-completion-484.test.js[1-10]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. Regression test modifies existing file 📜 Skill insight ⚙ Maintainability
Description
A new regression test for #484 was added to an existing test file instead of being introduced as a
new dedicated regression test file. This violates the guideline requiring regression tests to be
added only via new files.
Code

tests/dashboard-rollup-index.test.js[R320-321]

+test('#484 an in-place tasks/<id>/builder.json rewrite invalidates a stalled run entry', async () => {
+  const projectRoot = mkdtempSync(join(tmpdir(), 'rstack-rollup-childcontract-inval-'));
Relevance

● Weak

Requests to move regression tests into new files (instead of editing existing suites) were rejected
repeatedly.

PR-#475
PR-#488
PR-#503

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400748 requires adding regression tests only by creating new test files; the diff
shows a new #484 regression test appended to an existing test file.

tests/dashboard-rollup-index.test.js[320-361]
Skill: qa-testing

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A `#484` regression test was added by modifying `tests/dashboard-rollup-index.test.js`, but the policy requires regression tests to be introduced as new files (not by editing existing test files).

## Issue Context
Keeping regression tests in separate files helps isolate bug-history coverage and reduces churn in existing suites.

## Fix Focus Areas
- tests/dashboard-rollup-index.test.js[320-361]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/integrations/pi/rstack-sdlc.ts
Comment thread tests/extension-terminal-completion-484.test.js Outdated
Comment on lines +3605 to +3613
if (status === "PASS" && !manifest.completed_at) {
try {
const freshTasks = JSON.parse(await readFile(tasksPath, "utf8")).tasks;
if (await isRunEligibleForCompletion(projectRoot, manifest, freshTasks)) {
await markRunCompleted(projectRoot, manifest.run_id);
}
} catch (completionError) {
console.error("Failed to mark run completed:", completionError);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

5. Completion-error path untested 📜 Skill insight ▣ Testability

A new try/catch swallows failures when stamping run completion, but there is no test that forces
this error branch to run and asserts the intended behavior.
Agent Prompt
## Issue description
The new error-handling path for completion stamping (`catch (completionError) { console.error(...) }`) has no test that triggers the failure path and verifies behavior.

## Issue Context
If `readFile(tasksPath)` / JSON parsing / `markRunCompleted()` fails, the code intentionally logs and continues. The compliance requirement asks for tests that trigger newly-added error-handling branches.

## Fix Focus Areas
- src/integrations/pi/rstack-sdlc.ts[3605-3613]
- tests/extension-terminal-completion-484.test.js[68-110]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/integrations/pi/rstack-sdlc.ts Outdated
Comment thread tests/extension-terminal-completion-484.test.js
Comment thread tests/extension-terminal-completion-484.test.js Outdated
Comment thread src/observability/dashboard/state/rollup-index.js
Comment thread src/observability/dashboard/state/rollup-index.js
richard-devbot and others added 2 commits July 31, 2026 20:53
… nits

Real correctness bug found by Qodo review of PR #519, confirmed against
the actual sdlc_plan writer: childContractsSignature() joined a task's
output_dir under runDir, but sdlc_plan writes output_dir as PROJECT-ROOT-
relative (.rstack/runs/<runId>/tasks/<taskId>), not run-dir-relative — so
in production every stat() targeted a nonexistent doubly-nested path and
the whole signature was a silent no-op. The fix builds paths from the
canonical <runDir>/tasks/<taskId>/ location using each task's id directly,
with containment checking (defense in depth against a corrupted/tampered
tasks.json carrying a traversal segment in id) — closes both the
correctness bug and the related path-injection finding Qodo flagged
separately. The regression test's fixture previously used a run-dir-
relative fake output_dir that accidentally matched the buggy join and
masked the bug entirely; now matches the real production shape, plus a
new dedicated traversal-rejection test.

Also addressed from the same review: removed a duplicated comment block
in sdlc_status, replaced `any` with a narrow structural type in
isRunEligibleForCompletion, cleaned up temp test directories + a magic-
number test delay, and renamed a non-descriptive loop variable.

Consciously not addressed: a test for the completion-stamp catch block's
error-swallowing path. Triggering that exact mid-function race
deterministically would require either timing-dependent hacks or
exporting internal functions solely for test access — not proportionate
for a three-line best-effort catch, and the finding was rated "Review
recommended," not "Action required."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…w CI

CI failed on Node 18.x only: "/api/state carries a strong ETag and
revalidates 304 once settled" got 200 instead of 304. Not reproducible
locally across 5 runs. The settle loop above this assertion breaks as soon
as it observes two consecutive identical ETags, but nothing guarantees
state stays stable in the gap between that check and the very next fetch —
an async index-write from the next background poll cycle can still land in
that window (the same class of race the loop's own comment documents for a
prior Node-22 CI flake this test already survived once). #484's
childContractsSignature adds more per-poll disk I/O (stat calls per task's
child contract files), plausibly widening that window just enough to
reproduce on the slowest CI runner.

Retries the revalidation itself with the same pacing as the settle loop,
re-observing the new ETag on a miss, instead of assuming post-loop
stability holds instantaneously.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@richard-devbot

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@richard-devbot

Copy link
Copy Markdown
Owner Author

@strix-security please re-run

@strix-security

Copy link
Copy Markdown

Strix is installed on this repository, but we couldn't run this PR security review because this workspace's trial has ended. Add a card to resume code reviews here.

@richard-devbot
richard-devbot merged commit 4b088e1 into main Jul 31, 2026
9 checks passed
@richard-devbot
richard-devbot deleted the fix-484-terminal-completion-child-contracts branch July 31, 2026 15:46
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.

1 participant