Skip to content

Fix local-only picker silently skipping on first-time enroll (issue #281)#283

Merged
philcunliffe merged 4 commits into
masterfrom
fix/issue-281
Jul 7, 2026
Merged

Fix local-only picker silently skipping on first-time enroll (issue #281)#283
philcunliffe merged 4 commits into
masterfrom
fix/issue-281

Conversation

@philcunliffe

Copy link
Copy Markdown
Contributor

Problem

On a first-time hyp remote login enrollment, the interactive local-only directory picker (LLP 0069/0080, #270) silently shows nothing and skips to its fallback tip. It only ever renders the multiselect on a re-login or a machine with pre-existing history, never on the fresh enroll that is its own primary trigger (LLP 0069 R6 / #trigger).

Root cause (confirmed)

Ordering. runBrowserLogin (src/core/cli/remote_commands.js) ran runLocalOnlyPicker before enrollCentralSink installs the daemon and the daemon backfills ai_gateway_messages. At picker time the cache is empty, listCapturedDirectoriesSELECT ... FROM ai_gateway_messages returns [], and the picker takes its no_candidates branch, printing only the durable-command tip. The multiselect never renders and ~/.hyp/hypaware/usage-policy/ is never created.

LLP 0044 ("ordering vs first ingest") + action_attach.js confirm attach markers land before the multi-minute backfill subprocess, so waiting on attach alone is not enough. executeQuerySql rediscovers on-disk partitions at query time, so the login process can see the daemon's freshly-backfilled rows cross-process.

Fix

Defer the picker per fork to the point its cache is populated:

  • Fresh-enroll (auto-daemon): run the picker after waitForClientAttach, gated on a new bounded, best-effort waitForCapturedDirectories poll that waits for the backfilled cache to populate, then runs the picker against it.
  • --no-daemon: run the picker against the as-is cache; no forwarding daemon exists yet, so the list still lands before any export (R6 holds here).
  • Re-login / re-seed: run the picker against the cache a prior daemon already filled (editor semantics), unchanged.

Invariants preserved (LLP 0069/0072/0080): TTY-gated, defaults to excluding nothing, never blocks or aborts enrollment, best-effort.

R6 trade-off (flagged, not silently changed)

Deferring past enrollCentralSink narrows R6's "not forwarded, even once" to "withheld from every export tick after the pick" on the auto-daemon fresh-enroll path only (a one-time backfill window may forward before the user picks). Closing that window fully (hold forwarding until the pick, or backfill locally before the forward daemon starts) is deferred to a follow-up. LLP 0069 §trigger and LLP 0080 §login carry a forward-ref note pointing here rather than a rewrite of their settled decisions.

Tests

Two regression tests assert the new ordering (fresh-enroll picker runs after enroll+attach and sees the now-populated candidates; --no-daemon picker runs after the sink is provisioned). Both fail on pre-fix code and pass after. Full suite green (2105 pass).

🤖 Generated with Claude Code

Fixes #281

philcunliffe and others added 2 commits July 7, 2026 15:18
)

On a fresh `hyp remote login` enrollment the interactive local-only
directory picker (LLP 0069/0080) showed nothing and skipped to its
fallback tip. Root cause is ordering: `runBrowserLogin` ran the picker
before `enrollCentralSink` installs the daemon and the daemon backfills
`ai_gateway_messages`, so at picker time the cache was empty,
`listCapturedDirectories` returned [] and the picker took its
`no_candidates` branch. The picker only ever rendered on a re-login or a
machine with pre-existing history, never on the fresh enroll that is its
own primary trigger (LLP 0069 R6 / #trigger).

Fix: defer the picker per fork to the point its cache is populated.

- Fresh-enroll (auto-daemon) fork: run the picker after
  `waitForClientAttach`, gated on a new bounded, best-effort
  `waitForCapturedDirectories` poll. Attach markers land before the
  multi-minute backfill subprocess (LLP 0044 "ordering vs first ingest"),
  so waiting on attach alone is not enough; the poll waits for the cache
  the picker reads to populate. `executeQuerySql` rediscovers on-disk
  partitions at query time, so the login process sees the daemon's
  freshly backfilled rows cross-process.
- `--no-daemon` fork: run the picker against the as-is cache; no
  forwarding daemon exists yet, so the list still lands before any
  export (R6 holds here).
- Re-login / re-seed fork: run the picker against the cache a prior
  daemon already filled (editor semantics), unchanged.

This narrows R6's "not forwarded, even once" to "withheld from every
export tick after the pick" on the auto-daemon fresh-enroll path (a
one-time backfill window may forward before the user picks). Closing
that window fully is deferred to the issue #281 follow-up; the LLP
0069/0080 records carry a forward-ref note rather than a rewrite.

Invariants preserved: TTY-gated, defaults to excluding nothing, never
blocks or aborts enrollment, best-effort (a failed/empty enumeration
never breaks the login it refines).

Regression tests assert the new fresh-enroll ordering (picker runs after
enroll+attach and sees the now-populated candidates) and the --no-daemon
ordering; both fail on pre-fix code and pass after.

Fixes #281

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…--no-daemon ordering note

Two additive review findings on the #281 fresh-enroll picker reordering:

- fresh-enroll auto-daemon fork: the bounded waitForCapturedDirectories poll
  only helps when a client attached this run (attach triggers the post-attach
  backfill, LLP 0044). With nothing attached there is no source to populate the
  cache, so the poll just burned its full 30s budget in silence before the
  picker skipped to the durable hint. Gate the wait on attached.length > 0 and
  otherwise run the picker directly against the cache as-is. Invariants hold:
  still bounded, still best-effort, still never blocks enrollment; the #281
  regression test (attached > 0) is unchanged and passes.

- LLP 0069/0080 forward-ref notes: corrected the imprecise claim that
  --no-daemon "keeps the pre-provision ordering". The --no-daemon fresh-enroll
  fork now runs the picker after enrollCentralSink writes the sink config, but
  installs no forwarding daemon this run, so the list still lands before any
  export and R6's substantive guarantee holds. Wording now matches the code and
  the test name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral review round (review rung) - PR #283

Reviewed head: ed2ac3aa47c4095705d7e56352346c5143215183
Verdict: approve-with-fixes (all findings fixed on this branch). Fixed head: 6bf1103440d74112690a3a3ea91a373adb9bf875
Reviewers: Codex ran successfully this round (no interception failure) + independent Claude diff read + full test suite.

The fix is correct and the load-bearing invariants all hold. The picker deferral is placed correctly in each of the three forks and the null/[] poll distinction is sound. Two major-tagged findings (both surfaced by Codex and independently by my own read) were actionable and are fixed additively in 6bf1103.

Invariant check (all hold)

  • Bounded / never blocks: waitForCapturedDirectories returns immediately on null (no engine / engine error, caught -> null) or a non-empty list, otherwise polls to a 30s wall-clock deadline (sleep(min(interval, remaining))) and times out to the last empty result. It cannot hang or abort enrollment.
  • null vs []: correct - null = enumeration cannot run (stop, picker shows durable hint); [] = backfill may still be landing (keep polling).
  • TTY-gated / defaults to excluding nothing / best-effort: the picker gates on isTty(stdin) && isTty(stderr), defaults to zero exclusions, and refineLocalOnly catches any picker throw and treats it as zero exclusions. Cancel (Ctrl-C) leaves the prior on-disk list untouched (no silent wipe).
  • Three forks: fresh-enroll auto-daemon (after attach + captured-dir wait), --no-daemon (after sink seed), re-login/re-seed (against the populated cache). No fork runs the picker against the pre-population cache; grep confirms no stray pre-enroll picker call remains.

Findings (2, both fixed)

1. [major -> fixed] Dead 30s silent wait when no client attached / non-TTY - src/core/cli/remote_commands.js:658 (reviewed head).
On the fresh-enroll auto-daemon fork the bounded waitForCaptured poll ran unconditionally. When attached.length === 0, attach never fired, so the post-attach backfill (LLP 0044) has no source - the cache stays empty and the poll burns its full 30s budget in silence before the picker skips to the durable hint. The sibling attach-wait deliberately announces itself precisely because "blocking silently for up to 30s reads as a hang"; this poll had no such announce. Fix: gate the wait on attached.length > 0; when nothing attached, run the picker directly against the cache as-is (immediate enumerate + durable-hint path). The TTY sub-case Codex also flagged was deliberately not gated on TTY: makeCtx's stderr has no isTTY, so a TTY gate would break the new #281 regression test's pickerSawCount===1 assertion and risk the regression coverage. The attached>0 gate is behavior-preserving (same end result minus the dead wait) and leaves the #281 test path unchanged. Verified: remote_commands.js:658 now branches on attached.length > 0; 51/51 login tests + full suite (2105 pass, 0 fail) green.

2. [major -> fixed] LLP note said --no-daemon "keeps the pre-provision ordering" - llp/0069-...spec.md, llp/0080-...design.md.
Inaccurate: the --no-daemon fresh-enroll fork now runs the picker after enrollCentralSink writes the sink config (the test is even named "...runs after enrollCentralSink provisions the sink on --no-daemon"). The substantive R6 guarantee still holds (no forwarding daemon is installed this run, so the list lands before any export), but the prose "pre-provision" was factually wrong. Fix: tightened both forward-ref notes to say --no-daemon is post-provision-but-pre-forwarding, so R6 holds unchanged there. Doc-only, zero code risk. This corrects new prose introduced by this PR; no settled LLP decision was altered.

R6 trade-off verdict: acceptable and correctly documented

The narrowing (auto-daemon fresh-enroll only: "not forwarded even once" -> "withheld from every export tick after the pick") is a real relaxation, but it is inherent to the reuse-the-cache design and is a net privacy improvement over the shipped bug: before this PR the fresh-enroll picker enumerated an empty cache and skipped silently, so a first-time user could exclude nothing and everything forwarded forever. After, they can exclude, at the cost of a bounded one-time backfill window. Closing that window fully would need a bigger change (hold forwarding until the pick, or a local pre-daemon backfill to populate candidates before any forwarding sink exists) - genuinely more than a cheap tweak. It is documented as forward-ref notes on LLP 0069 §trigger and 0080 (not a rewrite of settled decisions), with a follow-up tracked on issue #281. Correct call.

Test quality

The two regression tests genuinely pin the ordering: the fresh-enroll test asserts deepEqual(order, ['enroll','attach','picker']) and pickerSawCount === 1 (the picker sees the backfilled candidate, not the empty pre-enroll cache); the --no-daemon test asserts seedExistedWhenPickerRan === true. Both fail on master (picker ran first / no waitForCaptured seam). Residual Codex nit (no direct unit test of the real waitForCapturedDirectories empty->populated / timeout / null-first behavior) is a coverage gap, not a defect - left as-is.

Anchors (#trigger, #enumerate, LLP 0072 #default/#tty) all resolve.

…re fork too

Codex round-2 finding (a real regression the #281 picker reordering
introduced): the pre-281 code ran the local-only picker once pre-provision,
covering every enrolling-login fork including the daemon-install-failure early
return. The reordering moved the picker to per-fork post-population points but
left the `result.daemonCode !== 0` fork with no picker call, silently dropping
the enrollment-time picker / durable hint there.

Mirror the --no-daemon fork: run refineLocalOnly against the cache as-is before
returning the daemon code. No forwarding daemon runs on a failed install, so the
list still lands before any forwarding can start once the user finishes
'hyp daemon install' (R6 holds on this fork too).

Tests: pin the picker runs on the daemon-fail path (fails without the fix), and
pin the round-1 nothing-attached skip of waitForCaptured (asserts the bounded
poll is not called and the picker still runs against the cache as-is).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral PR-health review — round 2 (head 6bf1103)

Verdict: findings — 1 major found and fixed (additively), plus the 2 round-1 fixes confirmed. Fix pushed as e2f59eb (additive fast-forward, no force-push). Held for human merge.

Round-1 fixes — both confirmed correct at this head

  1. Dead 30s wait gated (src/core/cli/remote_commands.js:664-669): on the auto-daemon fresh-enroll fork the bounded waitForCaptured poll now runs only when attached.length > 0; with nothing attached it skips to refineLocalOnly({ ctx, stateDir, picker }) (no listCandidates). The null-vs-empty distinction is preserved: omitting listCandidates lets the picker enumerate the real cache rather than force-empty it. Correct.
  2. LLP wording (llp/0069-...spec.md, llp/0080-...design.md): the --no-daemon fork is now described as post-provision but pre-forwarding ("runs the picker after enrollCentralSink writes the sink config… installs no forwarding daemon this run, so the list lands before any export, R6 holds"). Verified factually accurate against the code. Correct.

Invariants hold: picker is bounded/never-blocks (errors caught in refineLocalOnly, return codes unaffected), defaults to zero exclusions, and every populated fork places the picker post-population. Regression tests still pin the fork order ['enroll','attach','picker'] and pickerSawCount === 1.

New finding (Codex, major) — FIXED

  • Major — the picker was silently dropped on the daemon-install-failure fork. src/core/cli/remote_commands.js:625-627. The pre-local-only picker shows nothing on first-time enroll: enumerates cache before the post-enroll backfill populates it #281 code ran the local-only picker once, pre-provision, covering every enrolling-login fork including the result.daemonCode !== 0 early return. The local-only picker shows nothing on first-time enroll: enumerates cache before the post-enroll backfill populates it #281 reordering moved the picker to per-fork post-population points (--no-daemon, auto-daemon success, re-login) but left the daemon-fail fork with no picker call — a regression from base: the enrollment-time picker / durable hint (LLP 0069/0072) was lost there. No forwarding daemon runs on a failed install, so like --no-daemon the picker must still run before returning; when the user later finishes hyp daemon install, forwarding starts with exclusions already set (R6 holds).
    • Fix (e2f59eb): mirror the --no-daemon fork — await refineLocalOnly({ ctx, stateDir, picker }) (cache as-is) before return result.daemonCode.
    • Tests added: one pins the picker runs on the daemon-fail path (verified it fails without the code fix); one pins the round-1 nothing-attached skip (asserts waitForCaptured is not called and the picker still runs against the cache as-is) — closing the test-gap both reviewers flagged.

Checks

  • npm test: 2107 pass / 0 fail / 1 skip (was 2105; +2 new).
  • node --test test/core/remote-login-command.test.js: 53 pass, ~150ms (no dead 30s real wait on the empty-attach path).
  • ref-check: no @ref errors in any PR-touched file (the corpus-wide anchor-drift errors are pre-existing and in unrelated files).
  • No em dashes / no trailing semicolons in the additions.

Reviewers: Codex ran successfully (caught the major both Claude passes missed) + two parallel Claude review subagents (bug/contract and guidance/tests) + independent diff read.

On the auto-daemon fresh-enroll path, the bounded waitForCapturedDirectories
poll (up to CAPTURE_WAIT_DEFAULT_MS) ran whenever a client attached, even on a
non-interactive login where the picker it feeds immediately no-ops on a non-TTY
stream (LLP 0072 #tty). A scripted enroll (e.g. 'hyp remote login 2>file') could
silently stall for a candidate list it would never show.

Mirror the picker's exact TTY decision (ctx.stdin ?? process.stdin, ctx.stderr)
before the wait: skip the poll when it cannot prompt and run the picker as-is so
it takes its own durable-hint path immediately. Interactive logins are
unchanged. Make the existing after-attach test an interactive (TTY) ctx and add
a non-TTY regression test asserting the wait is skipped with clients attached.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 3 - findings (1 major, fixed)

Reviewed head e2f59eb (the round-2 daemon-fail-fork fix). Confirmation of the round-2 change, plus one new finding Codex surfaced that I judged actionable and fixed additively.

Round-2 fix: confirmed correct and complete

Both Codex and my independent read agree the daemon-install-failure fork fix holds up:

  • Every enrolling-login fork now runs the picker exactly once, at a point its cache is populated:
    • --no-daemon fresh enroll -> refineLocalOnly (cache as-is), remote_commands.js:622
    • daemon-install-failure (result.daemonCode !== 0) -> refineLocalOnly before returning the code, remote_commands.js:636 (the round-2 fix)
    • auto-daemon fresh enroll, attached -> waitForCaptured then picker with the populated list, :675-676
    • auto-daemon fresh enroll, nothing attached -> picker as-is (no dead wait), :678
    • re-login / re-seed -> refineLocalOnly against the already-populated cache, :703
    • Non-enrolling / error returns (seed fail, enroll throw, connectedElsewhere, query-only, --no-forward) correctly do not run the picker.
  • The round-2 tests genuinely pin the fix. Mutation check: removing refineLocalOnly from the daemon-fail fork fails exactly a failed daemon install still runs the local-only picker before returning; restoring it passes. The nothing-attached test pins the waitForCaptured skip; the after-attach test pins the deferral + listCandidates injection.
  • Invariants hold: bounded/never-blocks (waitForCapturedDirectories has a deadline; refineLocalOnly catches), defaults to zero exclusions, picker failure caught and warned. LLP 0069/0080 doc edits correctly append forward-refs (immutable-doc convention respected).
  • Full suite green at e2f59eb (2107 pass).

Finding (major, fixed): non-TTY enroll stalled on the backfill wait

remote_commands.js (at e2f59eb): on the auto-daemon fresh-enroll path the bounded waitForCaptured poll (up to CAPTURE_WAIT_DEFAULT_MS, 30s) ran whenever a client attached - including on a non-interactive login, where the picker it feeds immediately no-ops on a non-TTY stream (LLP 0072 #tty). A scripted enroll (e.g. hyp remote login 2>file, TTY stdin but redirected stderr) could silently stall up to 30s for a candidate list it would never show. Unlike the pre-existing attach wait (which reports capture state regardless of TTY), this wait exists only to fill the interactive picker, so on non-TTY it is pure waste. This is the one spot where "TTY behavior unchanged" did not hold.

Fix (additive commit 1bf83a7): mirror the picker's exact TTY decision (isTty(ctx.stdin ?? process.stdin) && isTty(ctx.stderr)) before the poll; skip it when the picker cannot prompt and run the picker as-is (it takes its own durable-hint path immediately). Interactive logins are unchanged. Made the existing after-attach test an interactive (TTY) ctx and added a non-TTY regression test asserting the wait is skipped even with clients attached. Verified: full suite 2108 pass / 0 fail; mutation check confirms the new test fails if the TTY guard is removed.

Verdict

The round-2 daemon-fail fix is correct and complete and no fork is left without a picker. One bounded non-TTY-latency major (introduced by the round-1 backfill wait) fixed additively. Hold for human merge.

Codex: ran (confirmed all three fix validations correct; raised this finding, rated major/high). Reviewed head e2f59eb; new head after fix 1bf83a7.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 4 (final) — clean

Head reviewed: 1bf83a753f59a91aa9a1a254586c2fccbcf0522a. Verdict: clean, no actionable blocker/major. Reviewers: Codex (ran, no new issues) + independent Claude read. node --test test/core/remote-login-command.test.js = 54/54 pass.

Round-3 TTY-gate fix: verified correct and complete

The gate at src/core/cli/remote_commands.js:682 is an exact De Morgan mirror of the picker's own skip condition:

  • Picker skips when !isTty(stdin) || !isTty(stderr) with stdin = ctx.stdin ?? process.stdin, stderr = ctx.stderr (src/core/commands/local_only.js:140,156).
  • Gate: const canPromptPicker = isTty(ctx.stdin ?? process.stdin) && isTty(ctx.stderr) — precisely the negation, same stream fallbacks. The poll runs only when the picker could actually prompt.

The fresh-enroll auto-daemon fork then polls waitForCaptured only when attached.length > 0 && canPromptPicker (:683); every other case (nothing attached, or non-TTY) skips straight to the picker enumerating the cache as-is. No dead 30s stall on a scripted enroll or a nothing-attached login. Directly test-covered:

  • non-TTY + attached asserts capturedCalled === false (test/core/remote-login-command.test.js, "no dead 30s stall").
  • nothing-attached asserts capturedCalled === false and picker still runs.

All forks place the picker correctly; invariants hold

  • Fresh-enroll auto-daemon (:683-688): after attach + bounded backfill wait, picker fed the populated list.
  • --no-daemon (:622) and daemon-install-failure (:636): picker runs directly against the cache as-is before return (round-2 regression stays fixed). R6 holds because no forwarding daemon runs this pass.
  • Re-login / re-seed (:712): picker as a list editor against the already-populated cache.
  • Query-only / --no-forward (:553,563): picker never invoked.

Invariants confirmed: TTY-gated, defaults to zero exclusions, never blocks or aborts enrollment (refineLocalOnly catches non-cancellation failures and warns; waitForCapturedDirectories is bounded and never throws). LLP 0069/0080 carry honest additive forward-refs (Revisited-by / Superseded-placement) that correctly narrow R6's "not forwarded, even once" to "withheld from every tick after the pick" on the auto-daemon path only, with the one-time backfill window tracked to the #281 follow-up.

Non-blocking observation (not a finding)

The waitForCaptured poll (up to CAPTURE_WAIT_DEFAULT_MS = 30s) has no "waiting..." stderr announcement, unlike the adjacent attach wait (:650). It is materially different from the round-1 dead wait: it only runs on an interactive login with a client already attached, where backfill is actively landing rows and the poll returns on the first non-empty result. The full budget is reached only in the rare attached-but-no-history case. A one-line announcement would be a nicety, not a correctness issue. Left as-is.

Nothing changed this round. Holding for human merge.

@philcunliffe philcunliffe merged commit 6877fe1 into master Jul 7, 2026
4 checks passed
@philcunliffe philcunliffe deleted the fix/issue-281 branch July 7, 2026 23:05
philcunliffe added a commit that referenced this pull request Jul 8, 2026
#281 follow-up) (#286)

After the #283 reordering the picker ran at the right time but queried
through the wrong registry: hyp remote login boots its kernel at dispatch
time, before enrollment, and on a first-run box the local config names no
plugins. The login process's query-registry snapshot therefore never
registers ai_gateway_messages - that registration arrives with
@hypaware/ai-gateway, which the org config-control pull enables only after
enrollCentralSink installs the daemon. listCapturedDirectories then failed
with "unknown dataset" (best-effort null), waitForCapturedDirectories read
null as "enumeration cannot run, stop now" (no 30s poll), and the picker
took its enumeration_failed durable-hint path on exactly the fresh enroll
it exists for. The #283 tests stubbed waitForCaptured/listCandidates, so
the gap was never exercised; a re-login on a populated box worked, which is
why manual verification passed.

Fix: freshenCaptureEnumeration (src/core/cli/remote_commands.js). On the
fresh-enroll interactive fork, after waitForClientAttach returns a client,
re-boot one fresh kernel (config profile, the same layered resolution any
subsequent hyp command runs) and hand its registry's enumeration to the
capture wait. One re-boot is sufficient by construction: attach markers are
written by the reconciler that runs only after a confirmed config apply, so
attach means the pulled central layer is on disk - and the attach handler
requires the gateway capability, so that layer enables the plugin. The
snapshot-already-has-it case (re-login on a populated box) skips the boot
entirely; a failed re-boot or a still-missing dataset falls back to the
prior behavior. Best-effort throughout: the refinement never breaks the
login it refines (LLP 0072). The handle is disposed (sources.stopAll,
mirroring dispatch) after the picker so no boot-started source keeps the
login process alive.

local_only.js exports CAPTURE_DATASET so the registry check and the
enumeration SQL cannot drift. LLP 0069/0080 carry forward-ref notes per
the immutable-record convention.

Tests: seam-level coverage of the refresh wiring (fresh enumeration is
polled, disposed after the picker; null refresh falls back), unit coverage
of freshenCaptureEnumeration (snapshot no-op, success, still-missing,
boot-throw), and - the #283 lesson - one deliberately unstubbed test that
runs the REAL bootKernel against an on-disk central layer naming
@hypaware/ai-gateway and asserts enumeration RUNS (empty list, pollable)
instead of failing to null.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
philcunliffe added a commit that referenced this pull request Jul 8, 2026
…l (LLP 0093) (#287)

* Fix local-only picker still skipping on a genuinely fresh enroll (issue #281 follow-up)

After the #283 reordering the picker ran at the right time but queried
through the wrong registry: hyp remote login boots its kernel at dispatch
time, before enrollment, and on a first-run box the local config names no
plugins. The login process's query-registry snapshot therefore never
registers ai_gateway_messages - that registration arrives with
@hypaware/ai-gateway, which the org config-control pull enables only after
enrollCentralSink installs the daemon. listCapturedDirectories then failed
with "unknown dataset" (best-effort null), waitForCapturedDirectories read
null as "enumeration cannot run, stop now" (no 30s poll), and the picker
took its enumeration_failed durable-hint path on exactly the fresh enroll
it exists for. The #283 tests stubbed waitForCaptured/listCandidates, so
the gap was never exercised; a re-login on a populated box worked, which is
why manual verification passed.

Fix: freshenCaptureEnumeration (src/core/cli/remote_commands.js). On the
fresh-enroll interactive fork, after waitForClientAttach returns a client,
re-boot one fresh kernel (config profile, the same layered resolution any
subsequent hyp command runs) and hand its registry's enumeration to the
capture wait. One re-boot is sufficient by construction: attach markers are
written by the reconciler that runs only after a confirmed config apply, so
attach means the pulled central layer is on disk - and the attach handler
requires the gateway capability, so that layer enables the plugin. The
snapshot-already-has-it case (re-login on a populated box) skips the boot
entirely; a failed re-boot or a still-missing dataset falls back to the
prior behavior. Best-effort throughout: the refinement never breaks the
login it refines (LLP 0072). The handle is disposed (sources.stopAll,
mirroring dispatch) after the picker so no boot-started source keeps the
login process alive.

local_only.js exports CAPTURE_DATASET so the registry check and the
enumeration SQL cannot drift. LLP 0069/0080 carry forward-ref notes per
the immutable-record convention.

Tests: seam-level coverage of the refresh wiring (fresh enumeration is
polled, disposed after the picker; null refresh falls back), unit coverage
of freshenCaptureEnumeration (snapshot no-op, success, still-missing,
boot-throw), and - the #283 lesson - one deliberately unstubbed test that
runs the REAL bootKernel against an on-disk central layer naming
@hypaware/ai-gateway and asserts enumeration RUNS (empty list, pollable)
instead of failing to null.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Hold sink exports behind a bounded pick-pending marker on fresh enroll (LLP 0093)

Closes the one-time forwarding window the issue #281 reordering left open:
after PR #283 deferred the local-only picker past enrollCentralSink, the
daemon that enroll installs could attach, backfill, and start exporting
rows before the user finished the pick. Withholding is enforced at
export-read time, and the server has no retraction path, so rows from a
directory the user was about to withhold forwarded once, permanently -
narrowing LLP 0069 R6's "not forwarded, even once" on exactly the
fresh-enroll path the picker exists for.

Decision (LLP 0093): the enrolling login writes a machine-local
pick-pending marker (usage-policy/pick-pending.json, co-located with the
local-only list) BEFORE enrollCentralSink, so no daemon tick can beat it
onto disk; the kernel sink driver skips whole export ticks while the
marker is fresh (held: 'pick_pending' in the tick report); the login
clears the marker in one finally covering every exit from the
fresh-enroll fork. Freshness is mtime with a 10-minute TTL and stale or
unreadable markers read as absent, so a crashed or abandoned login can
never stall exports indefinitely - the hold is a bounded refinement,
never a kill switch (LLP 0072). Held rows stay in the cache and export on
the first tick after the pick; watermarks make that a plain catch-up.

Enforcement is driver-wide rather than per-sink: the driver cannot know
which sinks leave the machine without a new registration concept, and
briefly deferring a local sink is harmless where one missed forward hold
is a permanent leak. Rejected alternatives (backfill-before-forward
reordering, off-machine-only gating, unbounded hold) are recorded in the
decision doc. Re-logins and --no-forward/query-only logins never write
the marker.

Tests: marker module (fresh/clear/stale-unlink/injected-now/fail-open),
driver hold (fresh marker holds the whole tick, cleared or expired marker
exports), and login lifecycle (marker lands before enroll, persists
through the pick, cleared on return, on enroll error, and on non-TTY;
never written on re-login).

Stacked on the issue #281 follow-up fix (fresh-enroll registry refresh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 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.

local-only picker shows nothing on first-time enroll: enumerates cache before the post-enroll backfill populates it

1 participant