Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion llp/0129-init-wizard-fork.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
**Author:** Phil / Claude
**Date:** 2026-07-22
**Related:** LLP 0011, LLP 0031, LLP 0058, LLP 0063, LLP 0128
**Extended-by:** [LLP 0182](./0182-one-reconfigure-for-every-machine.decision.md) (§returning-gate below: the managed machine's separate scoped row and `'scoped'` pathway are withdrawn in favour of one `Reconfigure` for every machine; being managed now only pre-locks the org's picker rows); [LLP 0211](./0211-fork-copy-collect-first.decision.md) (§fork below: the fork's copy is re-worded to lead with what gets collected, shared listed first with per-row summaries, and an intro line explaining the product precedes it; the fork's mechanics and quit default are unchanged)
**Extended-by:** [LLP 0182](./0182-one-reconfigure-for-every-machine.decision.md) (§returning-gate below: the managed machine's separate scoped row and `'scoped'` pathway are withdrawn in favour of one `Reconfigure` for every machine; being managed now only pre-locks the org's picker rows); [LLP 0211](./0211-fork-copy-collect-first.decision.md) (§fork below: the fork's copy is re-worded to lead with what gets collected, shared listed first with per-row summaries, and an intro line explaining the product precedes it; the fork's mechanics and quit default are unchanged); [LLP 0223](./0223-converge-on-applied-config.decision.md) (§join-before-picker below: the bounded wait no longer reuses the login lane's reconcile-wait - it polls the applied org-config slot, so a config that attaches no clients still converges; budget and timeout fallback unchanged)

> Spawned by [LLP 0128](./0128-install-experience-overhaul.rfc.md) on
> acceptance. Amends [LLP 0011](./0011-setup-and-onboarding.decision.md):
Expand Down
8 changes: 8 additions & 0 deletions llp/0135-install-experience-overhaul.design.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,14 @@ The taxonomy and the classification it feeds are unchanged; how the wizard
stderr and substring-matched the messages it printed; the lane now returns
`{ exitCode, reason }` and `classifyLoginFailure` switches on the code.

Extended-by: [LLP 0223](./0223-converge-on-applied-config.decision.md).
The reuse-the-reconcile-wait sketch above is withdrawn:
`waitForCentralConverge` now runs its own bounded poll over the applied
org-config slot under `config-control/`, because an attach marker is
sufficient but not necessary evidence that the org config landed. It stays
a small exported helper of `remote_commands.js`, with the same budget and
the same unlocked-picker timeout fallback, and returns `{ ok }` only.

## Pick phase {#pick}

> **Extended-by [LLP 0190](./0190-wizard-defaults-gate.decision.md):**
Expand Down
78 changes: 78 additions & 0 deletions llp/0223-converge-on-applied-config.decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# LLP 0223: The join converge wait polls the applied org config, not client attaches

**Type:** Decision
**Status:** Accepted
**Systems:** Onboarding, CLI, Config
**Author:** Phil / Claude
**Date:** 2026-08-13
**Related:** [LLP 0129](./0129-init-wizard-fork.decision.md) (§join-before-picker: the wait this decision re-grounds), [LLP 0031](./0031-layered-config.decision.md) (§physical-layout: the active-slot/seed layout the new probe reads), [LLP 0063](./0063-login-auto-provision-forward-sink.decision.md) (the login lane whose attach wait stays as it is)

> Extends [LLP 0129 §join-before-picker](./0129-init-wizard-fork.decision.md#join-before-picker):
> the bounded wait, its budget, and the timeout-means-unlocked-picker
> fallback all stand. What changes is the convergence signal the wait
> polls.

## Context

LLP 0129 prescribes a bounded wait for the org's config between the
join lane's enrollment and the picker, so the picker can render the
org-owned rows locked. As first implemented, `waitForCentralConverge`
wrapped the login lane's `waitForClientAttach`: "converged" meant at
least one client attach marker on disk.

An attach is *sufficient* evidence the org config landed (the daemon
only attaches after pulling and applying it) but it is not *necessary*.
Two common steady states never produce an attach marker:

- an org config that pins sinks, policies, or retention but names no
client for this machine, and
- the no-org-config 404 steady state.

In both, the wizard burned the entire 60-second budget under the
"Applying your org's configuration..." spinner - on top of the login
lane's own 30-second wait over the very same markers - before falling
through to the unlocked picker. For the first state that is worse than
slow: the config *had* converged, often within seconds, and the rows it
owns were then rendered unlocked anyway.

## Decision

**"Converged" is the fact the join phase actually consumes: the daemon's
apply engine has committed a pulled org config to disk.** The probe is
the active-slot pointer under `config-control/`
(`hasAppliedCentralConfig`, a read-only wrapper over the same
active-slot read boot resolution uses). The pointer flips only when a
pulled document is applied (LLP 0031 §physical-layout), and boot's
central-layer resolution prefers the slot over the join seed, so the
layered resolution `computeCentralLockedSources` performs immediately
after the wait reads exactly the document that satisfied it.

Two deliberate non-signals:

- **The join seed is not convergence.** It exists the instant enrollment
writes it and names only `@hypaware/central`, so treating it as
converged would end the wait before the org's real config arrives and
lock nothing while claiming a managed picker.
- **Attach markers are no longer consulted here.** The login lane's own
attach wait is untouched: it answers a different question ("what is
being captured?") for which the attach marker is the right signal.

## Consequences

- An org config that attaches no clients on this machine converges in
roughly one pull-and-apply cycle instead of always timing out.
- A machine whose slot is already applied (a re-run) converges on the
first probe.
- The 404 no-org-config steady state still runs to the timeout: nothing
lands on disk for a probe to observe. Ending that wait early would
require the pull loop to persist its 404 outcome cross-process; out of
scope here, and the timeout fallback (unlocked picker, narrated) is
already the correct answer for it.
- `waitForCentralConverge` returns `{ ok }`; the attached-names list it
used to relay was never consumed by the join phase.

## References

- [LLP 0129](./0129-init-wizard-fork.decision.md) §join-before-picker
- [LLP 0031](./0031-layered-config.decision.md) §physical-layout
- [LLP 0063](./0063-login-auto-provision-forward-sink.decision.md)
81 changes: 62 additions & 19 deletions src/core/cli/remote_commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import os from 'node:os'
import path from 'node:path'
import process from 'node:process'

import { hasAppliedCentralConfig } from '../config/apply.js'
import { defaultConfigPath } from '../config/schema.js'
import { readObservabilityEnv } from '../observability/env.js'
import { BUILTIN_REMOTES, effectiveDefaultRemote } from '../remote/builtin_remotes.js'
Expand Down Expand Up @@ -186,30 +187,72 @@ export async function waitForGatewayBind({
}

/**
* Wait for the daemon's first reconcile to converge on the org config after
* an enrolling login, so the wizard's join phase can lock the org-owned
* picker rows before it composes (LLP 0129). "Converged" means at least one
* client attached: the daemon only attaches after it has pulled and applied
* the org config, so an attach is proof the central layer landed. This reuses
* the bounded attach-wait `runBrowserLogin` already performs internally
* (`waitForClientAttach`) rather than adding a second poll loop; the join
* phase gets a small `{ ok, attached }` verdict instead of the raw list.
* Wait for the daemon to converge on the org config after an enrolling
* login, so the wizard's join phase can lock the org-owned picker rows
* before it composes (LLP 0129). "Converged" means the apply engine has
* committed a pulled config to an active slot - the on-disk fact the
* locked-row computation reads right after this wait - NOT that a client
* attached: an org config that names no client for this machine converges
* without ever writing an attach marker, and polling the markers made this
* wait burn its whole budget in exactly that steady state. The join seed is
* deliberately not convergence either ({@link hasAppliedCentralConfig}).
*
* A timeout (the org has no published config - the no-config 404 steady
* state - or a slow first pull) returns `{ ok: false, attached: [] }`, and
* the wizard shows an unlocked picker rather than blocking. Timing out is not
* an error here for the same reason it is not one for `waitForClientAttach`.
* state, which lands nothing on disk to observe - or a slow first pull)
* returns `{ ok: false }`, and the wizard shows an unlocked picker rather
* than blocking. Timing out is not an error here for the same reason it is
* not one for `waitForClientAttach`.
*
* @ref LLP 0129#join-before-picker [implements]: the bounded org-config wait before the picker composes, reusing the login lane's own reconcile-wait instead of a second poll loop
* @param {{ env: NodeJS.ProcessEnv, homeDir?: string, probe?: () => Promise<string[]>, sleep?: (ms: number) => Promise<void> }} opts
* @ref LLP 0129#join-before-picker [implements]: the bounded org-config wait before the picker composes; budget and timeout fallback per the decision
* @ref LLP 0223 [implements]: convergence is the applied slot on disk, never the attach markers or the join seed
* @param {{ env: NodeJS.ProcessEnv, probe?: () => boolean | Promise<boolean>, sleep?: (ms: number) => Promise<void> }} opts
* @param {{ timeoutMs?: number, intervalMs?: number }} [waitOpts]
* @returns {Promise<{ ok: boolean, attached: string[] }>}
* @returns {Promise<{ ok: boolean }>}
*/
export async function waitForCentralConverge({ env, homeDir, probe, sleep }, { timeoutMs, intervalMs } = {}) {
// `waitForClientAttach` defaults `timeoutMs`/`intervalMs`/`sleep` on
// `undefined`, so forwarding an unset value keeps its own defaults.
const attached = await waitForClientAttach({ env, homeDir, timeoutMs, intervalMs, probe, sleep })
return { ok: attached.length > 0, attached }
export async function waitForCentralConverge(
{ env, probe, sleep = defaultSleep },
// The wizard passes its own budget (ORG_CONFIG_WAIT_MS); the fallback here
// quotes the attach wait's own constant so a budget-less call stays bounded
// and the two cannot drift apart.
{ timeoutMs = ATTACH_WAIT_DEFAULT_MS, intervalMs = 500 } = {}
) {
const stateRoot = readObservabilityEnv(env).stateDir
const applied = probe ?? (() => hasAppliedCentralConfig({ stateRoot }))
const deadline = Date.now() + timeoutMs
let loggedProbeError = false
for (;;) {
let ok = false
try {
ok = Boolean(await applied())
} catch (err) {
// A transient fs error mid-poll is "not converged this tick", never a
// join failure: keep polling to the timeout fallback. But an fs error
// that persists is indistinguishable at the wizard from the no-org-config
// steady state (both end in "didn't hear back"), so leave a signal for
// the run that has to be diagnosed. Once per wait, not once per poll: a
// durable EACCES would otherwise log for the whole budget.
//
// This only sees anything because `hasAppliedCentralConfig` throws
// rather than folding an unreadable pointer into `false`. A probe that
// swallows its own fs errors makes this branch dead code.
if (!loggedProbeError) {
loggedProbeError = true
getLogger('remote-login').warn('join.converge_probe_failed', {
[Attr.COMPONENT]: 'cmd-remote-login',
[Attr.OPERATION]: 'join.converge',
[Attr.ERROR_KIND]: 'converge_probe_unreadable',
error_message: err instanceof Error ? err.message : String(err),
})
}
ok = false
}
if (ok) return { ok: true }
const remaining = deadline - Date.now()
if (remaining <= 0) return { ok: false }
// Floor at 1ms so a non-positive intervalMs cannot busy-spin; cap at the
// remaining budget so we never oversleep it. Same guard as the attach wait.
await sleep(Math.max(1, Math.min(intervalMs, remaining)))
}
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/core/cli/wizard/join.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import { classifyClientProvenance } from './provenance.js'

/**
* The join phase's org-config convergence budget (LLP 0129: "seconds to a
* minute"). Reuses the login lane's own reconcile-wait via
* `waitForCentralConverge`; this is the ceiling the wizard is willing to
* block for a locked picker before falling through to an unlocked one.
* minute"). `waitForCentralConverge` polls for the applied org-config slot
* on disk (LLP 0223); this is the ceiling the wizard is willing to block
* for a locked picker before falling through to an unlocked one.
*/
export const ORG_CONFIG_WAIT_MS = 60000

Expand Down
2 changes: 1 addition & 1 deletion src/core/cli/wizard/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ export interface RunWizardJoinOptions {
waitForConverge?: (
opts: { env: NodeJS.ProcessEnv },
waitOpts: { timeoutMs?: number, intervalMs?: number }
) => Promise<{ ok: boolean, attached: string[] }>
) => Promise<{ ok: boolean }>
/**
* Override layered-config resolution (tests inject a fixture). Defaults
* to `resolveLayeredConfigFromDisk` over the on-disk local + central
Expand Down
37 changes: 37 additions & 0 deletions src/core/config/apply.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,43 @@ export function resolveCentralLayerPath({ stateRoot }) {
return null
}

/**
* Whether the daemon's apply engine has committed a pulled org config: the
* active-slot pointer resolves to a slot file. Read-only and safe from any
* process, like {@link resolveCentralLayerPath}, but deliberately blind to
* the join seed: the seed exists the instant enrollment writes it and names
* only the central plugin, so it is evidence of *enrollment*, not of the
* org's config having arrived.
*
* Unlike {@link resolveCentralLayerPath} this one *throws* on a pointer it
* cannot read. `readActiveSlot` answers `null` for both "no pointer yet" and
* "pointer unreadable", and the converge wait polling this has to tell them
* apart: the first is its ordinary steady state, polled to the timeout in
* silence, the second is a stuck host it has to log. Same
* ENOENT/ENOTDIR-is-the-answer discrimination
* {@link centralLayerResolutionFailure} makes.
*
* @param {{ stateRoot: string }} args
* @returns {boolean}
* @throws {NodeJS.ErrnoException} when the active pointer exists but cannot be read
* @ref LLP 0223 [implements]: the join converge wait's probe - applied slot, never the seed
*/
export function hasAppliedCentralConfig({ stateRoot }) {
const controlDir = path.join(stateRoot, CONTROL_DIRNAME)
if (readActiveSlot(controlDir) !== null) return true
// Only on the not-converged answer, so the happy path stays one readlink:
// re-read the pointer to separate a control directory that holds nothing
// yet from one this process cannot read (EACCES, EIO, a pointer that is
// not a symlink at all).
try {
fs.readlinkSync(path.join(controlDir, ACTIVE_BASENAME))
} catch (err) {
const code = err && /** @type {NodeJS.ErrnoException} */ (err).code
if (code !== 'ENOENT' && code !== 'ENOTDIR') throw err
}
return false
}

/**
* The central-layer file names {@link resolveCentralLayerPath} resolves
* *through*: the pointer it reads and the files it can name. Their presence
Expand Down
10 changes: 5 additions & 5 deletions test/core/cli/wizard/join.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ test('runWizardJoin: a non-zero login exit returns the classified failure and ne
let waited = false
const opts = joinOpts(cat, {
runLogin: async () => ({ exitCode: 1, reason: 'no_membership', stderr: 'hyp remote login: not a member\n' }),
waitForConverge: async () => { waited = true; return { ok: true, attached: ['claude'] } },
waitForConverge: async () => { waited = true; return { ok: true } },
})
const out = await runWizardJoin(opts)
assert.deepEqual(out, { status: 'failed', detail: 'hyp remote login: not a member\n', reason: 'no_membership' })
Expand Down Expand Up @@ -160,7 +160,7 @@ test('runWizardJoin: on convergence, locks exactly the central-layer picker rows
)
const opts = joinOpts(cat, {
runLogin: async () => ({ exitCode: 0, reason: 'ok', stderr: '' }),
waitForConverge: async () => ({ ok: true, attached: ['claude'] }),
waitForConverge: async () => ({ ok: true }),
resolveLayered: async () => lc,
})
const out = await runWizardJoin(opts)
Expand All @@ -175,7 +175,7 @@ test('runWizardJoin: convergence with no central-owned rows locks nothing', asyn
const lc = layered(['@hypaware/claude'], ['@hypaware/claude', '@hypaware/codex'])
const opts = joinOpts(cat, {
runLogin: async () => ({ exitCode: 0, reason: 'ok', stderr: '' }),
waitForConverge: async () => ({ ok: true, attached: ['claude'] }),
waitForConverge: async () => ({ ok: true }),
resolveLayered: async () => lc,
})
const out = await runWizardJoin(opts)
Expand All @@ -191,7 +191,7 @@ test('runWizardJoin: a convergence timeout narrates and returns an empty lock se
let resolved = false
const opts = joinOpts(cat, {
runLogin: async () => ({ exitCode: 0, reason: 'ok', stderr: '' }),
waitForConverge: async () => ({ ok: false, attached: [] }),
waitForConverge: async () => ({ ok: false }),
// resolveLayered must never run on the timeout path (nothing to lock).
resolveLayered: async () => { resolved = true; return layered([], []) },
})
Expand All @@ -207,7 +207,7 @@ test('runWizardJoin: passes the org-config wait budget through to the converge h
let sawWaitOpts = null
const opts = joinOpts(cat, {
runLogin: async () => ({ exitCode: 0, reason: 'ok', stderr: '' }),
waitForConverge: async (_o, waitOpts) => { sawWaitOpts = waitOpts; return { ok: false, attached: [] } },
waitForConverge: async (_o, waitOpts) => { sawWaitOpts = waitOpts; return { ok: false } },
})
await runWizardJoin(opts)
assert.ok(sawWaitOpts && typeof sawWaitOpts.timeoutMs === 'number' && sawWaitOpts.timeoutMs > 0)
Expand Down
4 changes: 2 additions & 2 deletions test/core/cli/wizard/progress.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ test('runWizardJoin: prints its position above the joining narration', async ()
catalog: { pickerDescriptors: new Map(), clientDescriptors: new Map() },
progress: 'Step 1 of 3 · Join your team',
runLogin: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
waitForConverge: async () => ({ ok: false, attached: [] }),
waitForConverge: async () => ({ ok: false }),
}))
assert.equal(
stdout.text().startsWith('Step 1 of 3 · Join your team\nJoining your team...\n'),
Expand All @@ -245,7 +245,7 @@ test('runWizardJoin: without a position it narrates exactly as it does today', a
env: {},
catalog: { pickerDescriptors: new Map(), clientDescriptors: new Map() },
runLogin: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
waitForConverge: async () => ({ ok: false, attached: [] }),
waitForConverge: async () => ({ ok: false }),
}))
assert.equal(stdout.text().startsWith('Joining your team...\n'), true, stdout.text())
})
Expand Down
Loading
Loading