Skip to content

fix(#709): anchor the resume/reaper test on the store's own clock - #710

Merged
Weegy merged 1 commit into
mainfrom
fix/pg-reaper-test-clock-race
Aug 16, 2026
Merged

fix(#709): anchor the resume/reaper test on the store's own clock#710
Weegy merged 1 commit into
mainfrom
fix/pg-reaper-test-clock-race

Conversation

@Weegy

@Weegy Weegy commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Closes #709. Follow-up to the CI-flake work in #703 / #708 — different mechanism, same class: a test whose pass/fail depended on how long the test process itself took.

The race

a RESUMED task survives the reaper: provideInput resets the liveness clock aged a parked task by sleep(120), resumed it, then swept with staleAfterMs: 60 and no now — so the reaper used real time.

That left a 60ms budget between provideInput returning and the sweep running. Any stall longer than that aged the freshly reset heartbeat past its own window, the reaper failed the task, and the test went red while the code under test was correct. The suite runs against both stores, so it exposed the normal npm test run and the schema (migrations on pgvector) job.

Reproduction — deterministic

An 80ms stall before the sweep, standing in for a GC pause or a loaded runner:

in-memory store real Postgres
before, +80ms stall the resumed task is NOT abandoned ✖ same
after, +80ms stall
after, +3000ms stall

The 3s case is the point: the race is eliminated, not widened.

The fix

const resumeAt = Date.parse(afterResume.updatedAt);
const result = await store.reapOrphans({
  now: new Date(resumeAt + 30),
  staleAfterMs: 60,
  purgeTerminalAfterMs: HOUR_MS,
});

provideInput writes updatedAt and lastHeartbeatAt in a single statement in both stores (last_heartbeat_at = now(), updated_at = now() in SQL; lastHeartbeatAt: ts, updatedAt: ts in memory). So updatedAt is stamped by the same clock as the heartbeat it is compared against — the database's for the durable store, the process's for the in-memory one. That matters: reapOrphans computes its cutoff in JS while the durable store's rows are stamped by Postgres, so an anchor invented in the test process would not be in the row's clock domain. This one is.

Why not the obvious alternatives

  • Lengthen the sleep / widen the window — only makes the race rarer and slows the suite. The budget still exists.
  • Pass an arbitrary now — breaks the cross-clock property above.
  • Drop the reaper assertion — loses the regression coverage this test exists for.

The anchor is not a tautology

updatedAt was chosen precisely because it stays correct when the guarded behaviour regresses: remove the lastHeartbeatAt reset and updatedAt still advances, so the frozen pre-park heartbeat lands outside the window and the test goes red.

Mutation-checked both ways:

Store mutation result
in-memory drop lastHeartbeatAt: ts from provideInput ✖ test red
Postgres drop last_heartbeat_at = now() from the resume UPDATE the resumed task is NOT abandoned

Both restored afterwards; only the test file and the changelog are in this diff.

Verification

Gate Result
npm run test green ×2, 6459 tests, 0 fail, 0 cancelled
pg conformance (real pgvector, throwaway DB) 15/15
npm run typecheck:test baseline 406, no regressions
npm run lint clean
core decoupling ratchet (#470) held at 3294
PG_TEST_FLOOR unchanged at 269 — no pg tests added or removed

The Postgres run used a scratch flakeprobe database created and dropped inside the existing dev container, so no developer data was touched.

No lengthened sleeps, no retries, no timeout bumps, no skips.


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

`a RESUMED task survives the reaper` aged a parked task by sleep(120), resumed
it, and swept with staleAfterMs: 60 using the reaper's default real-time `now`.
That left a 60ms budget between `provideInput` returning and the sweep running:
any stall longer than that -- a GC pause, a loaded CI box, the serial Postgres
job sharing a runner -- aged the freshly reset heartbeat past its own window, so
the reaper failed the task and the test went red while the code was correct.
Reproduced deterministically with an 80ms stall before the sweep, on both
stores.

The sweep now takes an explicit `now` anchored on the resume's own `updatedAt`.
`provideInput` writes `updatedAt` and `lastHeartbeatAt` in one statement in both
stores, so that timestamp comes from the same clock that stamped the heartbeat
being compared against -- the database's for the durable store, the process's
for the in-memory one. The test no longer depends on its own runtime; verified
green with a 3s injected stall.

`updatedAt` is the right anchor because it survives the regression the test
guards: dropping the `lastHeartbeatAt` reset still advances `updatedAt`, so the
frozen heartbeat lands outside the window and the test goes red. Mutation-
checked against both the in-memory store and real Postgres.

No sleeps lengthened, no retries, no skips. PG_TEST_FLOOR unchanged.

Closes #709
@Weegy
Weegy merged commit e4e892e into main Aug 16, 2026
7 checks passed
@Weegy
Weegy deleted the fix/pg-reaper-test-clock-race branch August 17, 2026 05:06
Weegy added a commit that referenced this pull request Aug 20, 2026
#774)

* feat(#578): credential keychain data model + store (phase 1/4)

Data model and durable store for the credential keychain (#578), the
credential-side counterpart to Privacy Shield: encrypted, fingerprinted
credentials owned by a principal, and grants (audience scope, once vs
standing, purpose, expiry, revocation) that let a principal use one.

No route, no tool yet — phase 1 is data model + storage only, per the
phase cut in docs/plans/phase4a-578-keychain-prompt-2026-08-20.md.

- packages/harness-channel-sdk/src/credentials.ts (NEW, additive):
  Credential / CredentialGrant / CredentialStore types,
  InMemoryCredentialStore, isGrantActive, validateNewGrantInput,
  fingerprintSecret. Barrel export appended to the END of index.ts to
  avoid merge conflicts with the parallel #577 session.
- src/credentials/crypto.ts: AES-256-GCM seal/unseal, reusing
  fileVault's resolveMasterKey under a DIFFERENT env var
  (CREDENTIAL_KEYCHAIN_KEY) and dev-key file — separate trust domain
  from the provider-secret vault, sharing only the key-resolution code.
- src/credentials/postgresCredentialStore.ts: durable CredentialStore,
  built the same way as PostgresGrantStore / PostgresAttachmentBindingStore
  (does not own the pool, throws rather than swallows a failure).
- src/credentials/credentialStoreFactory.ts: explicit Postgres-vs-in-memory
  choice, so the vault no-pool case is a stated decision, not an
  implicit fallback.
- migrations/0040_credentials.sql: credentials + credential_grants
  tables. 0038 is reserved (#746); 0039 is turn_receipts (#757).

Why a dedicated store instead of a second GrantStore: a credential
grant needs expiry/purpose/once-vs-standing metadata GrantStore's
capability-string model cannot express. The coarse layer (does this
principal have any right to reach the broker at all) still reuses the
existing GrantStore/resolveCapabilities mechanism in phase 2; this
table is the fine layer underneath it. Rationale is written out in
credentials.ts's module header and the migration's own comments.

Tests: 59 (49 unit + 10 against a real Postgres, skips cleanly with no
test DB configured). Mutation-tested: isGrantActive's expiry boundary,
principal-canonicalisation in activeGrant (an earlier version of this
test passed even with canonicalisation removed, because both
principals were built via makePrincipal which already canonicalises —
fixed to use a raw, non-canonical Principal literal so the store's own
canonicalisation is what's under test), the activeGrant active-filter,
and the revokeGrant/markGrantConsumed idempotency guards. Every mutant
was caught after the fix; dist was rebuilt between channel-sdk
mutation runs.

* fix(#578): generalise broker_header_name to broker_injection_key

headerName only made sense for the "header" injection scheme. Phase 2
(the broker) also needs a query-parameter NAME for the "query-param"
scheme, which had nowhere to go under the old field. Renamed before phase 2
lands on top of this, rather than working around the gap there:
"header" uses it as the header name, "query-param" as the parameter name,
"bearer"/"basic-password" ignore it (the whole secret IS the value).

No behavioural change for "bearer" (the only scheme phase 1's own tests
exercise) — this is a rename, not new logic.

* fix(#578): satisfy the test-tree typecheck ratchet (issue #573)

`fsp.readdir(dir).catch(() => [])` inferred the catch handler's return
as `never[]`, which the test/tsconfig.json project (checked separately
from src/ per #573) flagged as a new, previously-unbaselined error —
`npm run typecheck` (src-only) never saw it, only `npm run
typecheck:test` does, and that is what CI's "Typecheck (test + scripts
trees, ratchet)" step runs. Annotated the handler's return type
explicitly. CI failed on this before the Test steps even ran (they were
skipped, not green) — verified locally with `npm run typecheck:test`,
now reporting "406 known error(s), no regressions".

* feat(#578): credential broker — the egress-stamping layer (phase 2/4)

The broker: an agent names a `service` credential and describes a
request (host, method, path). The broker decides whether the calling
principal, right now, may use that credential for exactly that
request, and if so decrypts the secret and stamps it onto the outbound
call itself. The caller receives only the response, never the secret,
on either the success or the failure path.

- src/credentials/requestMatching.ts: normalizeHost/Method,
  normalizePathForMatch (traversal-safe via path.posix.normalize,
  which clamps `..` at the root), matchPath (boundary-safe prefix
  matching, not a bare startsWith).
- src/credentials/brokerMetrics.ts: counters built the same shape as
  securityScreenMetrics.ts (#749) per the scoping prompt's instruction
  to reuse that pattern — count every outcome, consecutive-denial
  streak alert.
- src/credentials/broker.ts: CredentialBroker. Fail-closed on every
  check (unknown/revoked credential, wrong kind, no active grant,
  host/method/path mismatch, malformed declaration, store outage).
  Every denial is counted and (when onAudit is wired) audited via
  BrokerAuditEvent — fingerprint only, never the secret.

Security-relevant design points, each with its own test:
- BrokerRequestDescriptor.host is compared against the credential's
  OWN declared host before dispatch — the SSRF-prevention check: an
  agent naming the right credential but the wrong destination denies
  with host-not-allowed rather than exfiltrating to an attacker host.
- Path prefix matching normalises both sides and requires a segment
  boundary, closing both the traversal hole (/api/../admin -> /admin)
  and the naive-startsWith hole (/v1 matching /v1extra).
- All non-mutating checks run BEFORE a `once` grant is consumed, so a
  request that was always going to be refused never burns the
  caller's single-use permission. The atomic markGrantConsumed call
  is the last gate before dispatch; its own false-return (lost a race
  to a concurrent use of the same grant) is a fail-closed denial
  (grant-consumed-concurrently), covered by a test that simulates the
  race via a store wrapper.
- A caller-supplied header cannot override or discover the injected
  Authorization/header/query-param value.
- query-param injection URL-encodes both the key and the secret.

Tests: 87 (59 unit covering requestMatching/brokerMetrics/broker in
isolation and via InMemoryCredentialStore, no external dependency).
Mutation-tested: matchPath's boundary check, normalizePathForMatch's
traversal clamp, the host-not-allowed check (SSRF guard), the check
ordering that protects a once grant from being burned by a
host/path-rejected request, query-param URL-encoding, and the
deny-path metrics counter. Every mutant was caught; all reverted.

* feat(#578): keychain-asks — request, owner-approval, grant (phase 3/4)

The full ask lifecycle: an agent requests a `personal` credential from
its owner; approval atomically creates the grant. Denial or expiry
resolves the ask without ever touching credential_grants.

Investigated whether Conductor's await mechanism
(middleware/src/conductor/awaitStore.ts) carries this before building
anything new, per the scoping prompt. It carries the PATTERN (kind/ref
principal, TTL evaluated against a caller-supplied `now`, atomic
claim-then-act) but not the table: conductor_awaits is FK'd NOT NULL to
a workflow run and step, and a keychain ask is neither — it is a
standalone request, not a step inside a Conductor workflow. Mirrored
the pattern in a dedicated table instead of forcing a fake run/step
onto every ask.

- migrations/0041_credential_asks.sql: credential_asks table. 0038
  reserved (#746), 0039 turn_receipts (#757), 0040 credentials (#578
  phase 1); this series continues at 0041.
- src/credentials/asks.ts: CredentialAsk / CredentialAskStore types,
  InMemoryCredentialAskStore. Asks only apply to `personal`
  credentials (assertAskableCredential) — a `service` credential has
  no single owner to ask; it is reached through the broker (phase 2)
  under an administratively-issued grant.
- src/credentials/postgresCredentialAskStore.ts: the durable store.
  approve() is the one method in this whole feature that opens its own
  transaction: claiming the ask and creating its grant must not be
  observable half-done, because an ask marked "approved" with no grant
  behind it is a promise the broker cannot keep. The claim itself is a
  single atomic UPDATE ... WHERE status = 'pending' AND ask_expires_at
  > $now, closing the #709/#710 race the same way
  markGrantConsumed/ConductorAwaitStore.close already do — verified
  against a REAL Postgres with two genuinely concurrent connections
  racing approve() on the same ask (exactly one wins, exactly one
  grant row exists).
- src/routes/credentialAsks.ts: create / list-pending-for-owner /
  list-mine / approve / deny / cancel. Not mounted into
  middleware/src/index.ts yet — same deliberate choice phases 1 and 2
  made, to keep this phase's blast radius to new files only and avoid
  a merge collision with the parallel #577 session's own index.ts
  changes. A future integration step mounts it behind requireAuth like
  every other /api/v1/admin/* router.

Deliberately NOT in this phase: routing an ask into the requester's or
owner's live chat thread. That requires touching the
orchestrator/channel messaging surface, which the binding surface
separation for this issue keeps out of reach (agentBuilder.ts,
existing skill routes). An owner currently discovers a pending ask by
listing it, not by being pinged — wiring that notification is a
follow-up, stated explicitly in asks.ts's module header and the PR
body.

Tests: 43 (37 unit/route + 6 against a real Postgres including the
concurrency race). Mutation-tested: the TTL check in the atomic claim,
the pending-status guard that makes the claim exclusive (caught
directly by the concurrent-approval race test against real Postgres),
the rollback-on-second-write-failure path (a fake PoolClient asserts
ROLLBACK actually ran), and the route's once-mode validation guard.
Every mutant was caught; all reverted.

Full local verification on top of phase 1 + phase 2: npm run build,
npm run lint, npm run typecheck, npm run typecheck:test (406 known
errors, no regressions — same baseline), and the full npm run test
suite. One unrelated pre-existing failure observed locally
(ConductorWebhookSubscriptionStore (pg), 18 sub-tests) — confirmed to
be schema drift on the long-lived shared local dev Postgres container
(that table is missing a run_id column a later migration added; my
only DROP TABLE commands during this work targeted credentials/
credential_grants with no CASCADE, which would have failed had
anything else depended on them) — not caused by this branch and not
expected to reproduce against CI's fresh Postgres container.

* chore(#578): renumber credential-asks migration 0041 -> 0043

0041 was claimed on main by 0041_receipt_hash_chain while this stack was in
flight; the sibling credentials migration moves 0040 -> 0042 on the P1
branch for the same reason. The migrator tracks by full filename and sorts
lexicographically, so this is hygiene, not correctness — but numbers only
help if they stay unique. 0038 remains reserved for #746. No code
references the filename (verified by grep).

* chore(#578): drop the stacked duplicate of the credentials migration

This branch was stacked on P1, which carried 0040_credentials.sql; on main
the same migration landed renamed as 0042_credentials.sql (0040 had been
claimed twice while the stack was in flight). After merging main in, both
filenames existed with byte-identical content — on a fresh install the
migrator (tracked by filename) would have applied the same DDL twice, and
the second CREATE TABLE would abort the run. Keep the canonical 0042.
Weegy added a commit that referenced this pull request Aug 20, 2026
…r content hash + reaper (#779)

* feat(#576): durable per-scope sandbox — P1 interface + Docker backend

Introduces @omadia/sandbox (middleware/packages/harness-sandbox): the narrow
Sandbox contract from issue #576 (qm competitive analysis) — provision/run/
read/write/list/teardown, optional capabilities (process-sessions/backup/
blob-staging) behind type guards rather than interface fields, and an
AgentComputerProfile declaring persistence/egress/process-session posture.

v1 backend is plain local Docker (DockerSandboxBackend), built on the same
injectable-spawn pattern as src/plugins/builder/buildSandbox.ts's
executeBuild seam (see dockerExec.ts's execDocker injection point) so the
full backend logic is testable with zero real Docker.

Wiring, not just declaration:
- profile.egress === false becomes docker run --network none. Proven at two
  levels: a stub-level argv assertion (always runs) and, behind the opt-in
  SANDBOX_DOCKER_TEST=1 gate, a real container attempting an outbound wget
  and observing it fail — not just that the flag was passed.
- read/write/list are traversal-hardened against a fixed sandbox root
  (pathGuard.ts's clampSandboxPathPosix), same discipline as the #772
  broker and zipExtractor.ts's zip-slip guard: absolute paths, NUL bytes,
  and any ../ resolution outside the root are rejected before a single
  docker exec is issued (asserted directly — traversal tests check the
  stub recorded zero calls).
- Container naming is a deterministic function of the scope key
  (sha256(scopeKey)[0:24]), so provision() re-attaches to an
  already-running container across backend-instance restarts without
  needing a DB-backed registry yet — that scope-durability bookkeeping
  (last-used timestamps for a reaper, RO-layer content-hash tracking,
  multi-backend routing) is P3's job, not a blocker for this backend
  working correctly today.

No orchestrator touch (by design — P1 scope per the phase cut). No new
runtime dependencies: the backend shells out to the docker CLI via
node:child_process, mirroring dev-runner-shim's dockerd.ts and
buildSandbox.ts's Node-builtins-only constraint.

Tests: middleware/test/sandbox/{pathGuard,agentComputerProfile,
dockerSandbox}.test.ts. 30 stub-tier tests (always run, no Docker
required) + 2 real-Docker tests gated on SANDBOX_DOCKER_TEST=1 (both
verified green locally against an actual daemon, including the egress
block). Root npm test / typecheck / build / lint all green with this
package included in the workspace chain.

Mutation-checked: inverting the egress condition in dockerSandbox.ts and
disabling the escape check in pathGuard.ts (with a full package rebuild
between runs) both broke the corresponding tests; reverted and confirmed
green again before committing.

* feat(#576): durable per-scope sandbox — P2 execute tool + command-policy gate

Adds the `execute` native tool (packages/harness-orchestrator/src/tools/
executeTool.ts), off by default behind operator config
`sandbox_execute_enabled` (honest-inert, same convention as the #575
audience floor and #580 command policy — sharper here because this tool's
entire job is running arbitrary commands).

## Security boundary — belt and braces, not a new mechanism

orchestrator.ts's dispatchTool already runs every tool call through
guardToolCommands (#580) at the ONE existing choke point, keyed on a
top-level `command` field — execute's input schema uses exactly that key,
so it is automatically gated by the EXISTING seam whenever a deployment
installs a commandPolicy provider on the turn context. No deployment does
that today (the seam is honest-inert until an operator config UI ships).

Relying solely on that opt-in seam would mean execute ships fully open in
every deployment that hasn't separately configured a policy — the #748
lesson (fail-open + no evidence = an invisible outage) applied to a much
sharper edge than a security screener. So the handler ALSO runs its own
command-policy check before ever touching a sandbox:
resolveCommandPolicy defaults to defaultCommandPolicy() (the
DEFAULT_ORG_FLOOR: recursive rm, force-push, destructive SQL, fork bombs,
pipe-to-shell), and a throwing resolver is FAIL-CLOSED — refused, never
run. require_approval is surfaced as an explicit refusal naming why, never
silently escalated. No parallel policy mechanism: both checks call the
same decideCommand/defaultCommandPolicy pure primitives from
@omadia/channel-sdk.

## Count, don't just log (#749/#750 pattern)

New commandPolicyMetrics.ts (mirrors securityScreenMetrics.ts): in-memory
counters for allowed/denied/require_approval/truncated/resolve_failed,
plus a per-rule-id tally. Wired into BOTH guardToolCommands (every
existing #580 branch now records) and executeTool's own belt-and-braces
check, so a broken policy resolver shows up as a resolveFailed streak
instead of silence.

## Sandbox wiring

execute resolves the calling turn's scope via ScopeId
(parseSessionScope/formatSessionScope from @omadia/channel-sdk), falling
back to a turn-unique key for an unscoped turn rather than a shared
literal (the #445 lesson). Provisions/reuses that scope's sandbox via the
injected SandboxBackend (DockerSandboxBackend in plugin.ts's wiring) and
runs the command there. Registered in harness-orchestrator's plugin.ts —
the SAME composition seam the existing ProcessMemory tools use (OB-76) —
not src/index.ts or agentBuilder.ts, neither of which this PR touches.

No writeCapabilities annotation: that contract is {dataClass, operation}
for structured-write idempotency dedupe; execute's effects are arbitrary
and untyped, and re-running the "same" shell command is not safely
deduplicable the way replaying a structured write is. Deliberately
absent, documented at the call site.

## Tests

test/sandbox/executeTool.test.ts (17 tests) — every deny/require_approval/
truncated/resolve_failed path proven to never call SandboxBackend.provision
(the security-boundary property), and the permitted path proven to reach
provision with the correct scope key + run options. test/
commandPolicyMetrics.test.ts (9 tests) — counter module plus
guardToolCommands wiring. All new + existing #580 tests green.

## Mutation-check evidence (dist rebuild between runs, per the phase-4b
## prompt's requirement now that @omadia/orchestrator imports @omadia/sandbox)

Short-circuited the deny-decision branch in executeTool.ts
(`if (false && decision.decision === 'deny')`), rebuilt @omadia/sandbox
and @omadia/orchestrator, reran the suite: both the rm -rf and force-push
denial tests failed as expected — the stub sandbox returned exit 0 for a
command that should never have reached it. Reverted, rebuilt again,
confirmed green.

Full workspace build/typecheck/lint/test green after merging latest
origin/main (7115 tests, 0 fail, 12 pre-existing skips).

* feat(#576): durable per-scope sandbox — P3 scope durability + RO-layer content hash + reaper

Migration 0044_sandbox_registry.sql (chosen with margin: 0040-0043 were
occupied by concurrent credential-work PRs at authoring time, including an
observed numbering collision at 0040 across two of them that has since
been resolved upstream — noted in the migration's own header for the
record, not something this PR touches).

## SandboxRegistry — the durable bookkeeping Docker doesn't give for free

New SandboxRegistry interface (get/upsert/touch/delete/listAll) in
@omadia/sandbox: InMemorySandboxRegistry (tests, and any deployment that
hasn't wired a durable store) and PostgresSandboxRegistry (migration
0044's sandbox_registry table). DockerSandboxBackend gained an OPTIONAL
registry constructor option — omitted (the default), the backend behaves
byte-identical to what P1/P2 shipped (deterministic container naming,
zero registry calls); provided, provision() records (scope, container
name, profile, lastUsedAt) and re-attaches via the registry's STORED
sandboxRef rather than recomputing the deterministic name, which is the
seam a future non-deterministic backend needs. Regression-tested directly
(dockerSandboxRegistry.test.ts's first suite asserts the no-registry path
is unchanged).

## Reaper — orphaned (idle, non-persistent) sandbox cleanup

reapOrphanedSandboxes takes "now" as a REQUIRED external parameter, never
derived from the registry entries themselves — the #709/#710 clock-race
lesson (an idle check must anchor to a clock independent of the row being
checked). profile.persistent === true entries are never reaped regardless
of idle time. A teardown failure leaves the registry row intact (for
retry) and is reported in failedScopeKeys rather than silently dropped,
and does not abort the sweep for the rest.

Not wired to a scheduler in this PR — the function is a complete, directly
callable, fully tested capability (same shape as DockerSandbox.teardown()
itself, which also isn't auto-invoked by anything); hooking it to a cron/
routine is a scheduling-system integration, out of #576's substrate scope.

## RO-layer content-hash materialization

computeContentHash (deterministic, order-independent, NUL-separated
path/content encoding so shifted-boundary inputs can't collide) +
syncReadOnlyLayer, which calls Sandbox.write only when the computed hash
differs from the caller-supplied previousHash. #576 is deliberately the
mechanism here, not the consumer: what actually constitutes a scope's RO
layer (org files, skills) is the issue's OWN framing of what "builds on"
this sandbox, not something #576 decides — inventing a content source
would be scope creep into a separate concept. The primitive is fully
exercised against a stub Sandbox (contentHash.test.ts proves the
skip-when-unchanged behavior directly against the write call count).

## Tests

- contentHash.test.ts (8), reaper.test.ts (6), dockerSandboxRegistry.test.ts
  (4) — all stub/pure, no Docker or Postgres needed, always run in npm test.
- postgresSandboxRegistry.pg.test.ts (5) — gated on GRAPH_PG_TEST_URL/
  MEMORY_PG_TEST_URL/DATABASE_URL, same probePgTest convention as
  postgresCredentialStore.pg.test.ts. Verified locally against a real
  postgres:16-alpine container (docker run, migration 0044 applied
  verbatim via psql, all 5 tests green) — not just trusted to work under
  CI's pg service.

## Mutation-check evidence (dist rebuild between runs)

Removed the persistent-skip guard ("if (entry.profile.persistent) continue;")
in reaper.ts, rebuilt @omadia/sandbox: the 'never reaps a persistent
sandbox' test failed as expected (a persistent scope was reaped).
Reverted, rebuilt again, confirmed green. (P1's egress/traversal
mutations and P2's deny-bypass mutation were separately re-verified in
their own PRs; this round targets the one new security-relevant branch
P3 adds.)

Full workspace build/typecheck/lint/test green after merging latest
origin/main (7223 tests, 0 fail, 12 pre-existing skips).

* fix(#576): reword dockerExec.ts comment to stop tripping the #470 core-decoupling ratchet

The comment referencing 'dev-runner-shim' by name matched the ratchet's
'dev-runner' literal pattern (scripts/check-core-decoupling.mjs), which
counts references to the Dev Platform being extracted in epic #470 — an
unrelated coincidence (dev-runner-shim is a real, unrelated package; the
ratchet's pattern list is deliberately broad-literal). Reworded to drop
the package name while keeping the same intent (Node-builtins-only
constraint on this spawn seam). Confirmed the ratchet passes locally
after this change: 'Dev Platform references held at 3296' (baseline
unchanged, not lowered — this was never a real Dev Platform reference,
just a name collision).

* fix(#576): sync package-lock.json peerDependencies entry for @omadia/orchestrator -> @omadia/sandbox

npm install after the P1 merge regenerated this — the peerDependencies
entry added to harness-orchestrator/package.json in the original P2
commit had not been reflected into package-lock.json's own copy of that
block.
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.

Flaky: the resume/reaper conformance test races the wall clock (60ms budget)

1 participant