Skip to content

Commit 642d1de

Browse files
philcunliffeclaude
andauthored
Fix local-only picker still skipping on a genuinely fresh enroll (issue #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>
1 parent e39596c commit 642d1de

5 files changed

Lines changed: 304 additions & 9 deletions

File tree

llp/0069-local-only-dir-selection.spec.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,23 @@ is **no half-enrolled window** (see [dismiss semantics, LLP 0072 §default](./00
133133
> the follow-up tracked in [issue #281]; this record's decision is otherwise
134134
> unchanged.
135135
136+
> **Revisited-by [issue #281] follow-up (fresh-enroll registry staleness).** The
137+
> #281 reordering above fixed *when* the picker runs but left *what it queries
138+
> with* stale: `hyp remote login` boots its kernel before enrollment, and on a
139+
> first-run box the local config names no plugins, so the login process's
140+
> query-registry snapshot never registers `ai_gateway_messages` — that
141+
> registration arrives with `@hypaware/ai-gateway`, which the org config-control
142+
> pull enables only after `enrollCentralSink` installs the daemon. The
143+
> [enumeration](#enumerate) then failed as "unknown dataset" (best-effort null),
144+
> the capture wait read null as "cannot run, stop", and the picker still
145+
> silently skipped on every genuinely fresh enroll. The fix
146+
> (`freshenCaptureEnumeration`, `src/core/cli/remote_commands.js`) re-boots one
147+
> fresh kernel after a client attaches — attach markers land only after the
148+
> confirmed config apply, so the pulled central layer is on disk by then and the
149+
> re-boot's merged config registers the dataset — and hands its enumeration to
150+
> the capture wait. Best-effort: a failed re-boot falls back to the durable
151+
> hint, never an error. Requirements and R6 semantics are unchanged.
152+
136153
The picker only makes sense on an **enrolling** login (one that provisions or
137154
already has a central sink). A `--no-forward` / query-only login
138155
([LLP 0063 D3](./0063-login-auto-provision-forward-sink.decision.md)) forwards

llp/0080-local-only-dir-selection.design.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,19 @@ enrollment it refines.
325325
> runs against the already-populated cache. Everything else in this design
326326
> (store, resolver, export seam, CLI, status) is unchanged.
327327
328+
> **Revisited-by [issue #281] follow-up (fresh-enroll registry staleness).** The
329+
> reordered wiring above still enumerated through the login process's boot-time
330+
> query-registry snapshot, which on a genuinely fresh box predates the org
331+
> config pull that enables `@hypaware/ai-gateway` — so `ai_gateway_messages` was
332+
> unregistered, the enumeration failed to null, and the picker still silently
333+
> skipped. The fix adds `freshenCaptureEnumeration`
334+
> (`src/core/cli/remote_commands.js`): after a client attaches (which
335+
> guarantees the pulled central layer is on disk), the login re-boots one fresh
336+
> kernel and hands its registry's enumeration to the
337+
> `waitForCapturedDirectories` poll. Best-effort and one-shot; every other part
338+
> of this design is unchanged. See
339+
> [LLP 0069 §trigger, the follow-up note](./0069-local-only-dir-selection.spec.md#trigger).
340+
328341
## CLI: the durable authoring path {#cli}
329342
330343
`hyp ignore` / `hyp unignore` / `hyp ignore --check`

src/core/cli/remote_commands.js

Lines changed: 113 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ import {
1616
writeSession,
1717
writeToken,
1818
} from '../remote/credentials.js'
19+
import { Attr, getLogger } from '../observability/index.js'
1920
import { readCentralSinkOrigins, seedLoginGateway } from '../remote/gateway_seed.js'
21+
import { bootKernel } from '../runtime/boot.js'
2022
import { enrollCentralSink } from '../commands/central.js'
21-
import { listCapturedDirectories, runLocalOnlyPicker } from '../commands/local_only.js'
23+
import { CAPTURE_DATASET, listCapturedDirectories, runLocalOnlyPicker } from '../commands/local_only.js'
2224
import { originOf } from '../remote/gateway_seed.js'
2325
import { isTty, readAllStdin } from './stdio.js'
2426
import { isPlainObject } from '../util/json_util.js'
@@ -135,7 +137,11 @@ export const CAPTURE_WAIT_DEFAULT_MS = 30000
135137
* picker uses (`listCapturedDirectories`, R2 - local cache only, never the remote).
136138
*
137139
* A `null` result means enumeration cannot run at all (no query engine, or an
138-
* engine error): stop immediately and let the picker take its skip path. Only a
140+
* engine error): stop immediately and let the picker take its skip path. On a
141+
* genuinely fresh enroll the boot-snapshot registry is such an engine (the
142+
* dataset registers only after the org config pull), so the caller first
143+
* refreshes it via `freshenCaptureEnumeration` and passes the fresh
144+
* `enumerate` in here rather than polling a query that can never run. Only a
139145
* valid-but-empty list means "backfill may still be landing", so keep polling
140146
* until the budget expires. Timing out returns the last (empty) result, and the
141147
* picker then shows the durable-command hint. Never throws and never blocks past
@@ -168,6 +174,94 @@ export async function waitForCapturedDirectories({ ctx, timeoutMs = CAPTURE_WAIT
168174
}
169175
}
170176

177+
/**
178+
* Rebuild a query context that can actually run the picker's candidate
179+
* enumeration on a genuinely fresh enroll (issue #281 follow-up).
180+
*
181+
* `hyp remote login` boots its kernel at dispatch time, before enrollment. On
182+
* a first-run box the local config names no plugins, so the boot snapshot in
183+
* `ctx.query` never registers the dataset the enumeration reads - that
184+
* registration arrives with `@hypaware/ai-gateway`, which the org
185+
* config-control pull enables only after `enrollCentralSink` installs the
186+
* daemon. Enumerating through the stale snapshot fails ("unknown dataset" -
187+
* null), which `waitForCapturedDirectories` correctly reads as "enumeration
188+
* cannot run, stop now": the picker silently skipped on exactly the fresh
189+
* enroll it exists for, even after the #281 reordering.
190+
*
191+
* The attach that gates the capture wait is also what makes one re-boot
192+
* sufficient: attach markers are written by the reconciler that runs only
193+
* after a confirmed config apply, so a successful `waitForClientAttach` means
194+
* the pulled central layer is on disk - and the attach handler requires the
195+
* gateway capability, so that layer enables the plugin. Booting one fresh
196+
* kernel here (config profile, the same layered resolution any subsequent
197+
* `hyp` command runs) yields a registry with the dataset; re-resolving per
198+
* poll tick would re-pay plugin discovery every second for nothing.
199+
*
200+
* Resolves to null whenever the caller should keep polling through its boot
201+
* snapshot: the snapshot already has the dataset (the populated-box re-login
202+
* case - no second boot is paid), the re-boot fails, or the merged config
203+
* still lacks the plugin. Best-effort throughout: never throws, so a broken
204+
* refresh can never break the login it refines (LLP 0072). Callers must
205+
* `dispose()` a non-null handle - plugins may start sources during
206+
* activation, and a live source handle would keep the login process alive
207+
* after it prints its result.
208+
*
209+
* @param {{
210+
* ctx: Pick<CommandRunContext, 'query' | 'storage' | 'config' | 'env'>,
211+
* boot?: typeof bootKernel,
212+
* }} args
213+
* @returns {Promise<{ enumerate: () => Promise<any[] | null>, dispose: () => Promise<void> } | null>}
214+
* @ref LLP 0069#enumerate [constrained-by]: the enumeration needs a registry that knows the dataset; on a fresh enroll only a post-attach re-boot has one
215+
*/
216+
export async function freshenCaptureEnumeration({ ctx, boot = bootKernel }) {
217+
try {
218+
if (ctx.query && ctx.query.getDataset(CAPTURE_DATASET)) return null
219+
const fresh = await boot({
220+
hypHome: readObservabilityEnv(ctx.env).hypHome,
221+
mode: 'cli',
222+
bootProfile: 'config',
223+
env: ctx.env,
224+
})
225+
const runtime = fresh.runtime
226+
const dispose = async () => {
227+
try {
228+
await runtime.sources.stopAll()
229+
} catch {
230+
// Best-effort, mirroring dispatch's own boot-source cleanup: a stop
231+
// failure is not actionable from the login path.
232+
}
233+
}
234+
const registered = !!runtime.query.getDataset(CAPTURE_DATASET)
235+
getLogger('remote-login').info('local_only.capture_registry_refresh', {
236+
[Attr.COMPONENT]: 'cmd-remote-login',
237+
[Attr.OPERATION]: 'local_only.capture_registry_refresh',
238+
status: registered ? 'ok' : 'failed',
239+
...(registered ? {} : { [Attr.ERROR_KIND]: 'dataset_still_unregistered' }),
240+
})
241+
if (!registered) {
242+
await dispose()
243+
return null
244+
}
245+
return {
246+
enumerate: () => listCapturedDirectories({
247+
query: runtime.query,
248+
storage: runtime.storage,
249+
config: fresh.config ?? ctx.config,
250+
}),
251+
dispose,
252+
}
253+
} catch (err) {
254+
getLogger('remote-login').info('local_only.capture_registry_refresh', {
255+
[Attr.COMPONENT]: 'cmd-remote-login',
256+
[Attr.OPERATION]: 'local_only.capture_registry_refresh',
257+
status: 'failed',
258+
[Attr.ERROR_KIND]: 'boot_failed',
259+
error_message: err instanceof Error ? err.message : String(err),
260+
})
261+
return null
262+
}
263+
}
264+
171265
/**
172266
* Run the local-only directory picker as a best-effort refinement of an
173267
* enrolling login. A non-cancellation failure (e.g. a corrupt existing list) is
@@ -248,7 +342,7 @@ export async function runRemoteAdd(argv, ctx) {
248342
*
249343
* @param {string[]} argv
250344
* @param {CommandRunContext} ctx
251-
* @param {{ login?: typeof loginWithBrowser, seed?: typeof seedLoginGateway, picker?: typeof runLocalOnlyPicker, enroll?: typeof enrollCentralSink, waitForAttach?: typeof waitForClientAttach, waitForCaptured?: typeof waitForCapturedDirectories }} [deps] test seam for the browser flow, gateway seeding, the local-only picker, central-sink enrollment, the post-enroll attach wait, and the post-backfill captured-directory wait
345+
* @param {{ login?: typeof loginWithBrowser, seed?: typeof seedLoginGateway, picker?: typeof runLocalOnlyPicker, enroll?: typeof enrollCentralSink, waitForAttach?: typeof waitForClientAttach, waitForCaptured?: typeof waitForCapturedDirectories, freshen?: typeof freshenCaptureEnumeration }} [deps] test seam for the browser flow, gateway seeding, the local-only picker, central-sink enrollment, the post-enroll attach wait, the post-backfill captured-directory wait, and the fresh-enroll registry refresh
252346
* @ref LLP 0058#d1 [implements]: browser mode of `hyp remote login`; one command, one store, one more way to populate it
253347
*/
254348
export async function runRemoteLogin(argv, ctx, deps = {}) {
@@ -313,6 +407,7 @@ export async function runRemoteLogin(argv, ctx, deps = {}) {
313407
enroll: deps.enroll ?? enrollCentralSink,
314408
waitForAttach: deps.waitForAttach ?? waitForClientAttach,
315409
waitForCaptured: deps.waitForCaptured ?? waitForCapturedDirectories,
410+
freshen: deps.freshen ?? freshenCaptureEnumeration,
316411
})
317412
}
318413

@@ -445,10 +540,10 @@ async function persistStaticToken(name, token, ctx) {
445540
* @param {string} name
446541
* @param {{ org?: string, host?: string, noBrowser: boolean, noForward: boolean, noDaemon: boolean }} opts
447542
* @param {CommandRunContext} ctx
448-
* @param {{ login: typeof loginWithBrowser, seed: typeof seedLoginGateway, picker: typeof runLocalOnlyPicker, enroll: typeof enrollCentralSink, waitForAttach: typeof waitForClientAttach, waitForCaptured: typeof waitForCapturedDirectories }} deps
543+
* @param {{ login: typeof loginWithBrowser, seed: typeof seedLoginGateway, picker: typeof runLocalOnlyPicker, enroll: typeof enrollCentralSink, waitForAttach: typeof waitForClientAttach, waitForCaptured: typeof waitForCapturedDirectories, freshen: typeof freshenCaptureEnumeration }} deps
449544
* @returns {Promise<number>}
450545
*/
451-
async function runBrowserLogin(name, { org, host, noBrowser, noForward, noDaemon }, ctx, { login, seed, picker, enroll, waitForAttach, waitForCaptured }) {
546+
async function runBrowserLogin(name, { org, host, noBrowser, noForward, noDaemon }, ctx, { login, seed, picker, enroll, waitForAttach, waitForCaptured, freshen }) {
452547
const remotes = await readConfiguredRemotes(ctx)
453548
const entry = remotes[name]
454549
if (!entry) {
@@ -661,8 +756,19 @@ async function runBrowserLogin(name, { org, host, noBrowser, noForward, noDaemon
661756
// runs and takes its own durable-hint path immediately.
662757
const canPromptPicker = isTty(ctx.stdin ?? process.stdin) && isTty(ctx.stderr)
663758
if (attached.length > 0 && canPromptPicker) {
664-
const captured = await waitForCaptured({ ctx })
665-
await refineLocalOnly({ ctx, stateDir, picker, listCandidates: () => Promise.resolve(captured) })
759+
// On a first-run box the boot snapshot in ctx.query predates the org
760+
// config pull that registers the dataset the enumeration reads, so the
761+
// capture wait would stop on its first "unknown dataset" failure and
762+
// the picker would silently skip (issue #281 follow-up). Refresh the
763+
// registry once (best-effort; null keeps the snapshot) so the poll can
764+
// actually see the backfill land, then poll and pick as before.
765+
const freshened = await freshen({ ctx })
766+
try {
767+
const captured = await waitForCaptured(freshened ? { ctx, enumerate: freshened.enumerate } : { ctx })
768+
await refineLocalOnly({ ctx, stateDir, picker, listCandidates: () => Promise.resolve(captured) })
769+
} finally {
770+
if (freshened) await freshened.dispose()
771+
}
666772
} else {
667773
await refineLocalOnly({ ctx, stateDir, picker })
668774
}

src/core/commands/local_only.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,13 @@ const MAX_SHOWN_CANDIDATES = 50
2727
// discoverable even when the interactive step is skipped (LLP 0072 #tty).
2828
const DURABLE_HINT = "tip: mark a directory local-only anytime with 'hyp ignore --local-only [path]'\n"
2929

30+
// The dataset the candidate enumeration reads. Exported so the login flow
31+
// can ask "can this kernel snapshot run the enumeration at all?" against the
32+
// query registry (remote_commands.js) without the name drifting from the SQL.
33+
export const CAPTURE_DATASET = 'ai_gateway_messages'
34+
3035
const ENUMERATE_SQL = `SELECT cwd, repo_root, COUNT(*) AS rows, MAX(date) AS last_seen ` +
31-
`FROM ai_gateway_messages WHERE cwd IS NOT NULL GROUP BY cwd, repo_root ORDER BY last_seen DESC`
36+
`FROM ${CAPTURE_DATASET} WHERE cwd IS NOT NULL GROUP BY cwd, repo_root ORDER BY last_seen DESC`
3237

3338
/**
3439
* Enumerate the distinct working directories the user has captured

0 commit comments

Comments
 (0)