Skip to content

Replace the latchkey skill with connect-external-service, and add the secret card - #638

Draft
gnguralnick wants to merge 51 commits into
mainfrom
gabriel/external-service-fallback-skill
Draft

gnguralnick wants to merge 51 commits into
mainfrom
gabriel/external-service-fallback-skill

Conversation

@gnguralnick

Copy link
Copy Markdown
Contributor

Paired with imbue-ai/mngr-internal#1137, which carries the plan (blueprint/external-service-fallback/plan-external-service-fallback.md) and the mngr side (the custom-service header).

The problem

The latchkey skill was the only guidance an agent had for reaching an outside service, and it ended at "ask for a new connection to a domain latchkey does not know". Past that an agent improvised: it asked the user to type a key into a file "with ample warning", or drove a browser with no guidance on when that was the right call. There was no way to take a secret from the user without the value entering the transcript.

What changes

One skill, connect-external-service, replaces latchkey. Its SKILL.md is a routing table with one row per method (builtin latchkey service, custom latchkey service, MCP server, direct API with a key from the secret card, browser): the cheap test that says whether the row is workable, the sentence to say, and the reference to read. The agent takes the first workable row and explains rather than asking. The latchkey text is references/latchkey.md; references/mcp.md and references/direct-api.md are new. The eight skills that named the latchkey skill, AGENTS.md, the build-app planning prompt, and the doc comments point at it.

The secret card. request_secret.py files a request through the chat app's new POST /api/secret-requests and prints it; every harness's parser recognises the call from its input and lifts the echoed request from its result, and the chat renders a card with one password input per variable, the rationale, the target file, and a note naming the variables a submit overwrites. Submit writes data/.secrets/<file>.env (mode 0600, POSIX single-quoted, merged into what the file held) and tells the agent the file and variable names; Decline sends the verdict and a note; a second request for the same file supersedes the first. Requests persist under data/.state/secret-requests/. The values reach the file and nothing else, and the tests assert that against the transcript notice, the persisted record, and the log.

The wrapper and the guards. system/scripts/with_secrets.py data/.secrets/<name>.env -- <command> is the one sanctioned reader of a secret file. Policy P8 (agent_secrets_guard.sh) refuses any other read or write of the directory, in shell commands (tokenized, bash -c strings unwrapped so a supervisord program passes) and in claude's, codex's, and pi's file tools; P9 holds a secret request to the same one-per-tool-call rule as a permission request, in the same checker. Registered for claude, codex, the agy shim, and pi (as the first checker that runs on every tool call).

Templates carry their secrets. app.toml gains [[secrets]] and SKILL.md front matter secrets:; publish-template aggregates them (plus the files .mcp.template.json and supervisord programs run under) into [[requirements.secret]] with file and variables, refuses to assemble on an undeclared reference or a variable missing from the publishing workspace, and ships an included .mcp.json as .mcp.template.json; use-template files one card per entry and merges servers into .mcp.json once their file is stored.

Decisions on the plan's open questions

  • The custom-service header is stored on the registeredServices entry under mindsCredentialHeader (mngr side).
  • enableAllProjectMcpServers: true in .claude/settings.json; the Claude Code settings reference documents it as approving every project .mcp.json server without a prompt.
  • Codex's MCP env values are literal and Claude expands ${VAR} from the harness environment, so the references document the wrapper for every harness; pi reads .mcp.json through pi-mcp-adapter, antigravity through .agents/mcp_config.json.
  • The guard's file-tool half does not reach antigravity, whose own file tools never reach a shell (recorded as partial, like P5).
  • A superseded request in another chat is told by a notice; same-chat supersession is handled by the transcript walk.

Verification

Unit and integration suites for system/scripts, the pi extensions, the chat app (Python and frontend), app_manifest, env_converge, and the publish-template scripts pass locally. The guard corpus was run once against a neutered checker to confirm it fails without the guard. The build-template assembly test that exercises the .mcp.json rename needs the workspace image's secret scanners and runs in CI. Manual verification in a dev workspace (the plan's phase 7) has not been done yet.

Gabriel Guralnick and others added 30 commits September 18, 2026 18:16
with_secrets.py is the one sanctioned reader of data/.secrets/<name>.env: it
loads the file's variables into a child process's environment and execs the
command. agent_secrets_guard.sh refuses any other read or write of the
directory, in shell commands (tokenized, with bash -c strings unwrapped so a
supervisord program passes) and in the file tools of claude, codex, and pi.
The filing checker now counts a run of request_secret.py as a filing, so a
secret request stands alone in its tool call exactly as a permission request
does. Registered for claude, codex, pi (as a payload checker over every tool
call), and the agy shim; documented as P8 and P9.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the request script

An agent asks for a credential by running the connect-external-service
skill's request_secret.py, which files a request through the chat app's new
POST /api/secret-requests and prints the filed request. The chat app persists
requests under data/.state/secret-requests/, supersedes a pending request for
the same file, and on submit merges the values into data/.secrets/<file>.env
(mode 0600, POSIX single-quoted, read back byte for byte by with_secrets.py)
before telling the agent the file and the variable names through the message
path. Every parser recognises the call from its input and lifts the echoed
request out of its result, and the classifier reads the stored / declined /
superseded notice by its tag, so the frontend can render a card from
structured fields.
A secret request renders as a card with one password input per variable, the
agent's rationale, the target file, and a note naming the variables a submit
overwrites; Submit and Decline (with an optional note) post straight to the
chat app, and the card flips to stored, declined, or superseded from the
transcript's notice, from the page's own submit, or from a status fetch after
a reload. The transcript walk lifts a secret request out of any open step
exactly as it does a permission request, resolves each card by its own request
id, and marks an earlier pending card for the same file superseded when a
later request appears.
One skill routes every external-service request: a routing table with one row
per method (builtin latchkey service, custom latchkey service, MCP server,
direct API with a key from the secret card, browser), each with its priority,
the cheap test that says whether it is workable, the sentence to say, and the
reference to read. The latchkey text becomes references/latchkey.md (with the
custom-service header and the row-4 pointer in place of the type-it-into-a-file
advice); references/mcp.md and references/direct-api.md are new. The eight
skills, AGENTS.md, the build-app planning prompt, the card's doc comment, and
the guard test comments point at the new skill; the browser-fleet skill's
worktree correction, the use-ai-integration delete-and-re-snapshot wording, the
build-app --secrets-file option and program shape, the service-processes and
scheduled-task wrapper rules, and the worker's report-not-request rule ride
along.
…ence at it

The skill's SKILL.md is a routing table with one row per method (builtin
latchkey service, custom latchkey service, MCP server, direct API with a key
from the secret card, browser): its priority, the cheap test that says whether
it is workable, the sentence to say, and the reference to read. The latchkey
text lives on as references/latchkey.md (with the custom-service header and a
pointer to row 4 in place of the type-it-into-a-file advice);
references/mcp.md and references/direct-api.md are new. The eight skills that
named the latchkey skill, AGENTS.md, the build-app planning prompt, the card's
doc comment, and the guard test comments point at the new skill. Alongside:
the browser-fleet skill's worktree correction, use-ai-integration's
delete-and-re-snapshot wording, build-app's --secrets-file option and program
shape, the service-processes and scheduled-task wrapper rules, and the
worker's report-not-request rule.
An app declares the env files it runs under in app.toml ([[secrets]]: file,
variables, note) and a skill under secrets: in its SKILL.md front matter.
publish-template's manifest writer aggregates those, plus every
data/.secrets/<file>.env the snapshot's .mcp.template.json and supervisord
programs name, into [[requirements.secret]] entries (file, variables, note;
the legacy bare name still loads) and the matching requires_secret: lines in
template.md, and refuses to assemble when a referenced file has no declaration
or a declared variable is missing from the publishing workspace's own file.
The validator flags a program running under an undeclared file, and an
included .mcp.json ships renamed to .mcp.template.json so nothing activates on
adoption before its secrets exist. use-template files one secret card per
entry up front and merges each entry's servers into .mcp.json once its file is
stored.
…d drop section banners

The routes were closures over the router's two capabilities; they are now
plain view functions that read a SecretRequestChatBridge the router attaches
to the app state (the interface lives beside the store so the state can
import it without a cycle), with a recording bridge as the test double. The
guard test's awkward value keeps its hash without tripping the trailing
comment ratchet, and the section banners are gone from every file this
branch touches.
…the chat app's state

The skill, the secrets README, and policy P8 said the guard "refuses every
other read", which promises more than a text-level checker delivers: it cannot
see what a program run under the wrapper does with its environment. Each now
says it is a backstop against a slip and that the rule is the agent's to keep.

Secret-request records move from data/.state/secret-requests/ to
data/.apps/chat/secret-requests/, where the rest of the chat app's state lives.

The transcript recogniser for a secret request now requires the script as a
whole word followed by its --file, the same "an argument, not a mention" test
the P9 checker applies, so a Read or Grep of the script no longer renders a
waiting card.
Problem: build_template.sh read $REQUIRES_SECRET_LINES 31 lines before the
variable was assigned and before write_template_manifest.py produced the file,
so under set -euo pipefail every assembly aborted with an unbound-variable
error (and without -u the lines would never have reached template.md, failing
the validator's markdown/TOML agreement check for any template with a secret).
Fix: move the read to just before the template.md heredoc, after the writer has
run; the scanner-gated assembly test now declares a secret in the demo app and
asserts the entry lands in both template.toml and template.md.
Problem: the comment sweep removed a leading '--' from lines that were not
banners: build_template.sh comments now named the options include,
data-include and skip-apt-check (the flags are --include, --data-include and
--skip-apt-check) and lost a bullet's indentation, and a mid-sentence dash in
claude/session_parser.py and a parenthetical's closing dash in turn-grouping.ts
left ungrammatical comments.
Fix: put the three flag names and the bullet indentation back, and rewrite the
two prose lines with a semicolon and a parenthesis so a later sweep cannot
repeat the damage.
Problem: test_a_program_running_under_an_undeclared_secret_file_is_flagged
constructed its declared manifest through a conditional expression whose
condition was a mkdir() side effect and whose else branch could never run, then
asserted the result was not None.
Fix: create the directory on its own line and assign the manifest directly.
Problem: _find_echoed_object serves both the permission-request and the
secret-request probe, but its warning on absurd JSON nesting still said
'permission-request probe', misleading whoever reads the log for a secret
request.
Fix: name it an echoed-request probe.
…xt render

Problem: hydrateStatus in secret-card.ts deleted the cache entry on a 5xx or a
network error and then called m.redraw(); that redraw re-entered
knownSecretResolution, which found no entry and fetched again, so while the
chat app was down every pending secret card fetched back to back.
Fix: cache a failed entry with a retry time (15 s, as latchkey-scope-info.ts
does), refetch only once it passes, and schedule one redraw at that time so an
idle page still retries; a test pins that a 502 is not refetched on the next
render and is after the delay.
Problem: agent_secrets_guard_check.py allowed any segment whose program was the wrapper without looking past its --, so 'with_secrets.py data/.secrets/svc.env -- cat data/.secrets/svc.env' passed the P8 guard and printed the file into the transcript.
Fix: the words after the wrapper's -- are judged as a command of their own (recursively, like a bash -c string), the P8 policy text and the secrets README say so, and the bypass is in the blocked corpus.
Problem: agent_latchkey_request_check_test.py nested a double quote inside a double-quoted f-string, syntax Python only accepts from 3.12; the repo targets 3.11, so ruff reports invalid-syntax and the lint gate fails.
Fix: hoist the removesuffix into a module constant and interpolate that.
…s written by hand

Problem: build-app/SKILL.md read as if --secrets-file wrote both the app.toml [[secrets]] declaration and the wrapped program command, but scaffold_flask_lib.py only wraps the command (it cannot know the variables), so an app scaffolded on that reading fails publish-template's declaration check.
Fix: state what the flag does and that the declaration is added by hand either way.
Problem: the README's list of API route families stopped at /api/latchkey after the branch added the secret-request routes.
Fix: name /api/secret-requests there with what it serves.
Problem: build_template.sh read the writer's requires-secret-lines file with '2>/dev/null || true', so a missing file would silently drop the requires_secret: lines from template.md and surface later as a markdown/TOML disagreement.
Fix: read it plainly; under set -e a failed substitution stops the script at the real cause.
Problem: the secret-echo assertion decoded its fixture with __import__("json").loads, an obscure spelling of a plain import.
Fix: import json at the top and use json.loads.
Problem: write_template_manifest.py declared SecretDeclaration as a hand-written class whose four-argument __init__ only stored its fields.
Fix: a stdlib @DataClass with the same fields, so the script stays runnable under a bare python3.
Problem: system/scripts/README.md names every utility script in the directory, but the new with_secrets.py (the one sanctioned reader of data/.secrets/, referenced by supervisord programs, .mcp.json, cron jobs, and three skills) was missing from the list.
Fix: added it to the utility-scripts bullet with a one-line description pointing at the secrets guard and the connect-external-service skill.
Problem: UnknownSecretRequestError mixed in KeyError, whose __str__ returns the repr of its argument, so the 404 detail the submit and decline routes build with str(e) came out wrapped in a stray pair of quotes.
Fix: mix in LookupError instead, which keeps the not-found meaning with the default __str__; the endpoint test now asserts the plain detail text.
…hook

Problem: secret-card.ts exported resetSecretStatusCacheForTesting, a
test-only hook whose sole purpose was to clear the module-level status
map between vitest cases; production code should carry no test hooks.
Fix: move the cache and its two operations onto a SecretStatusCache class
(known, note) with one module-level instance for the live card; the tests
build their own instances and need no reset.
Problem: scaffold_flask_lib.py spliced --secrets-file straight into the
supervisord program's bash -c string as data/.secrets/<name>.env without
checking it against the slug every other reader of that directory enforces,
so a name with a space, quote, slash, or uppercase letter was written into
the program command and only failed later at publish or process start.
Fix: validate the name up front with the same grammar and exit with the
scaffold's usual "error: --secrets-file ..." message, with a test that runs
the real script and checks nothing is written.
Problem: tool_output.py declared SECRET_REQUEST_SCRIPT as the one basename
the chat and the P9 checker key on, but the matcher on the next line
restated request_secret.py as a literal and nothing read the constant.
Fix: compile _SECRET_REQUEST_SCRIPT_RE from re.escape(SECRET_REQUEST_SCRIPT).
Problem: one line of the guard's "backstop, not a boundary" paragraph in
data/.secrets/README.md ran to about 170 characters in a file wrapped at 80.
Fix: rewrap the paragraph's last two sentences; no wording change.
…ice-fallback-skill

# Conflicts:
#	system/apps/chat/imbue/chat/server.py
#	system/libs/app_manifest/src/app_manifest/manifest.py
#	system/libs/app_manifest/src/app_manifest/primitives.py
Gabriel Guralnick and others added 21 commits September 21, 2026 13:04
Problem: every name validator on the secret-request path (secret_requests.py's
SecretFileName and SecretVariableName, request_secret.py's --file and --var,
write_template_manifest.py's declaration check, scaffold_flask_lib.py's
--secrets-file) tested its `^...$` pattern with re.match, and `$` also matches
just before a trailing newline. A variable named "FOO\n" was accepted and
written into data/.secrets/<file>.env as a bare "FOO" line followed by
"='value'", which with_secrets.py refuses to parse -- so the whole file, and
every other credential already in it, stopped loading for every program running
under it. A file named "svc\n" became the path data/.secrets/svc\n.env.

Fix: use fullmatch at each site, which is the check every one of those
docstrings already describes, and add the two trailing-newline cases to the
store's malformed-filing corpus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: format_secret_resolution_notice took verdict as a bare str, branched
on it with if/elif/else plus a SwitchError default, and the module repeated the
vocabulary as SECRET_RESOLUTION_VERDICTS -- a third spelling after
SecretRequestStatus and the frontend's SecretResolution. The three callers
passed string literals, so nothing checked the spelling and a fourth verdict
would have compiled while falling through. docs/system/style_guide.md asks for
a match statement with assert_never when branching on a finite set of values.

Fix: add SecretResolutionVerdict (LowerCaseStrEnum, same wire spellings),
derive SECRET_RESOLUTION_VERDICTS from its members, dispatch with match +
assert_never, and pass the enum from the submit, decline and supersede routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: agent_latchkey_request_check.classify() was extended to count a run of
request_secret.py as a filing (policy P9), and the module docstring, the
refusal wording and the wrapper were all reworded, but classify()'s own
docstring still said it judged only "a permission request" -- understating the
function's scope by half for anyone tracing where a block came from.

Fix: name both kinds of filing, matching the module docstring's wording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: `_files_secret_request` in
system/scripts/agent_latchkey_request_check.py counted any shell word whose
basename was `request_secret.py` as a filed secret request, with nothing to
corroborate that the segment ran the script. The permission half of the same
checker requires the POST method alongside the URL, and the chat's reader of the
same shape (`is_secret_request_call` in tool_output.py) requires the script's
`--file` -- its docstring claims the two apply the same test. They did not, so
`grep -rn request_secret.py .agents | wc -l`, `cat <the script> && echo done`
and `<the script> --help 2>&1` were all hard-blocked with "file ONE request per
tool call" for commands that file nothing.

Fix: a segment counts as a filing only when the script word is followed by its
required `--file` (either spelling argparse accepts), matching the card reader.
The corpus gains the three inspection commands as allowed cases; every blocked
case already carries the flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: tool-call-policies-state-of-things.md still said "three rows are
`partial`" after the diff added P8 (agy partial) and P9 (codex and agy partial)
to the at-a-glance table, which brings the count to five. The file's header
makes keeping the table current part of wiring a policy.

Fix: say five.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: .agents/skills/connect-external-service/scripts/request_secret.py had
no test beside it, though main() takes argv/environ/clock/sleep purely so a test
can drive it and file_request() implements a bounded retry against a chat app
answering 503. Nothing exercised the retry window, the chat-id fallback, the
run-outside-an-agent-shell exit, or the branch that lifts the chat app's own
reason out of a refusal; the subprocess test beside the chat app covered the
happy path and an unreachable app only.

Fix: add request_secret_test.py for the units (argument refusals, chat-id
resolution, the retry window through a fake clock, the no-chat-id exit), and add
one case to test_secret_requests.py that files against a chat the app does not
know, so the refusal path reports the chat app's detail rather than dumping its
answer. Both retry tests and the refusal test were confirmed to fail against a
broken retry loop and a dropped detail lift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: POST /api/secret-requests caught only InvalidSecretRequestError, but
SecretRequestStore.file_request() also reads the current
data/.secrets/<file>.env to report existing_variables and overwrites, and that
read raises SecretFileWriteError when the file exists and cannot be opened. The
exception escaped the view, so the agent got a bare 500 with no detail instead
of the error body every other route on this module returns -- request_secret.py
prints "refused the request (HTTP 500): None".

Fix: catch it and return a 500 with the store's message, the way submit()
already does. The message names the path and never a value. A test files
against a mode-000 env file and was confirmed to raise without the catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: system/scripts/agy_shim/bash still carried an ASCII banner above the
soft-policy section, in a file this branch edits (the guard loop gained
agent_secrets_guard.sh). Banners come out of every file a diff touches, and the
branch already swept them from every other file it touches.

Fix: keep the sentence, drop the rule and the trailing dashes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: `uv run ruff format --check` reported drift in the two test files this
iteration added lines to, while the same files were clean on origin/main. The
pre-commit hook did not reformat them on the way in.

Fix: run `uv run ruff format` on the two files. No behaviour change; the tests
still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ice-fallback-skill

# Conflicts:
#	.agents/skills/latchkey/SKILL.md
Problem: the branch deletes .agents/skills/latchkey/ in favour of
connect-external-service, but migrate_workspace.py's `retired-skill` audit
patterns -- which exist for "references to skills that were renamed, which a
migrated skill or doc will otherwise send the agent looking for" -- were not
extended. A source workspace's own docs saying "the `latchkey` skill" or
naming .agents/skills/latchkey/SKILL.md went unreported; the `latchkey` audit
kind only matches CLI calls and gateway URLs, not the skill.
Fix: added a path pattern (skills/latchkey) and a prose pattern
(latchkey` skill) to the retired-skill kind, with a test that both spellings
are flagged and a plain `latchkey curl` line is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: system/apps/chat/imbue/chat/harnesses/tool_output.py --
find_permission_request was generalised into _find_echoed_object, which now
serves the secret-request path too, but its three constants kept
permission-only names and comments (_PERMISSION_REQUEST_ID_KEY,
_MAX_PERMISSION_REQUEST_LENGTH, _MAX_PERMISSION_REQUEST_PROBES). A reader of
the secret path could not tell whether those bounds applied to it.
Fix: renamed them to _REQUEST_ID_KEY, _MAX_ECHOED_REQUEST_LENGTH and
_MAX_ECHOED_REQUEST_PROBES, reworded their comments to say echoed request,
and updated the one test that imports the probe cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: swapping "the `latchkey` skill" for the full path
.agents/skills/connect-external-service/references/latchkey.md pushed four
prose lines to 102-126 columns in .agents/skills/minds-api/SKILL.md and
.agents/skills/migrate-workspace/SKILL.md, whose paragraphs otherwise wrap at
~80.
Fix: refilled those four paragraphs. Wording unchanged; only line breaks move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: SecretRequestStore._write_env_file writes every value into
data/.secrets/.<file>.env.tmp before renaming it into place. A failure in the
write, the chmod or the rename was turned into SecretFileWriteError and
returned, leaving that copy of the values on disk indefinitely -- a file the
wrapper will not load (it takes only .env), the user will not see (a dotfile),
and nothing else cleans up.
Fix: unlink the temporary in a finally, so a success (where the rename already
moved it) is a no-op and a failure leaves nothing behind; a failure to unlink
is logged without a value and does not mask the real error. Added a test that
forces the rename to fail and asserts the secrets directory holds no leftover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: the same substitution fixed in minds-api and migrate-workspace left
long lines in five more ~80-column-wrapped skills: use-template (two),
github-sync, publish-template, update-published-template and
update-installed-template.
Fix: refilled those paragraphs. Wording unchanged; only line breaks move.
AGENTS.md is left alone -- its paragraphs are single unwrapped lines already.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: the test boots chat-app --preflight as a subprocess and polls its
health route with a 30s wait_for, then allows the shutdown up to 15s in its
finally. The chat suite's pytest-timeout default is 10s, so the harness cap
sat well below the test's own declared budget: on a loaded machine the boot
took 10.3s and the test was killed mid-wait, while it passes in under 4s on
a quiet one. The failure was a property of the cap, not of the code.

Fix: mark the test with an explicit 60s timeout, covering the boot wait and
the shutdown it already budgets for. Not marked flaky: the cause is known
and this removes it rather than retrying around it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: test_path_watcher_stop_is_idempotent failed once during a loaded,
parallel run of the whole chat suite. The cause is not established: stop()
joins a watchdog observer thread, and the suite's 10s pytest-timeout can
catch that join under contention, but 30 solo runs did not reproduce it and
solo runs are not the condition it failed in.

Fix: mark it flaky so offload retries it, and record what is and is not known
beside it. Unlike the pre-flight boot test, there is no declared budget here
that the cap undercuts, so raising a timeout would be guessing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: test_path_watcher_stop_is_idempotent was marked flaky on the grounds
that, unlike the pre-flight boot test, there was no declared budget for the 10s
cap to undercut. There is one. PathWatcher.stop() joins the watchdog observer for
up to 5s and then the watcher thread for up to 5s, and the test calls stop()
twice, so the body declares up to 20s against the suite's 10s func-only cap. Over
50 runs the background thread never reached _ensure_observers() before the first
stop(), so the observer was always created during it and the second stop() is the
one that stops and joins a live observer -- the join the comment already named.
The flaky mark alone also changes nothing here: CI runs `uv run pytest` per
package, with no offload lane and no pytest-rerunfailures to honor it.

Fix: add an explicit 30s timeout covering the 20s the code under test declares,
and say that in the comment instead of the session's run counts. The flaky mark
stays, because the cause is still unconfirmed and removing it would claim
otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: the hydration retry test pumped a fixed 6 microtask hops and then
asserted the refetched status had landed. Reading a fetch Response's body takes
4 of those 6 on macOS, so the margin was two hops, and on CI's Linux runner the
read needs more than six: the test failed there with `expected null to be
'stored'` while passing locally. vi.useFakeTimers() compounds it by replacing
setImmediate, so any turn of that body read which goes through a scheduler
cannot run at all, and how many turns it takes is a platform detail.

Fix: wait on the signal SecretStatusCache ends a hydration with -- it stores the
entry and then asks for a redraw -- yielding whole scheduler turns, bounded, with
a message naming the shortfall. Fake only the clock the test drives (setTimeout
and Date), leaving setImmediate real. The afterEach now restores spies too: they
outlive their test, so the second vi.spyOn(m, "redraw") was handing back the
first test's spy with its calls still counted, which made the new wait return
before the first fetch had landed.

Verified by control: at three hops the old test reproduced CI's failure on the
same line, and with the retry-delay gate removed the new test still fails on the
refetch count rather than passing vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: settleHydrations in secret-card.test.ts read the redraw count at the
top of each iteration and yielded at the bottom, so all 100 checks preceded
their yield and the outcome of the final one was never inspected. A hydration
that settled on that last turn still threw, and because the message re-reads the
count at throw time it reported a number satisfying its own expectation.
Bisecting the bound reproduced it exactly: at `turn < 1` the retry test failed
with `hydration never settled: 1 redraws, expected 1`.

Fix: yield first, then check, leaving one check site. Every check now follows a
yield, so the throw is reachable only when the count is genuinely short after
all 100 -- which is the whole point of having a bound. Confirmed in both
directions: at a bound of 1 the test now passes, and at a bound of 0 it still
throws, reporting a truthful `0 redraws, expected 1`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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