Skip to content

Commit 4a4d792

Browse files
philcunliffeclaude
andauthored
Manual hyp detach retracts its attach marker so rejoin re-attaches (#217) (#223)
* Manual `hyp detach` retracts its attach marker so rejoin re-attaches (#217) Manual `hyp detach` reversed a client's on-disk settings but left an orphaned `status: "done"` attach marker in `client-actions.json`. The action reconciler is level-triggered against that marker, so the next `hyp join`'s forward gap short-circuits on `done` and never re-attaches: detach-via-config-drop was rejoin-recoverable while detach-via-CLI was not, and the two disagreed side by side in `hyp status`. Fix: after a successful disk reversal, `detachClientViaCore` now retracts the client's `attach` marker via a new `clearClientActionMarker` helper on the reconciler module (which owns the marker store) — the same `delete markers[requestKey]` the reconciler's `reverse()` does once its disk undo succeeds. This keeps the single core undo's two call sites (CLI detach and reconciler reverse) from drifting, honoring LLP 0045 §Part 3 (the marker is a self-describing undo record that must not outlive its own effect being reversed). The retraction is best-effort and atomic (tmp+rename, mode 0600), and runs even on a no-op reversal so a stale marker over already-clean settings is still cleared. Regression test drives the full join -> detach -> rejoin cycle end to end: the real reconciler attaches (marker done + settings written), the real `hyp detach` command reverses settings and retracts the marker, and a second reconcile re-attaches while an un-detached sibling stays attached. It fails on master at the marker-retract assertion and passes with the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Detach marker retract: preserve the #212 probe-less invariant + cover the guard Dual-review fixes on the #217 fix (PR #223). Finding 2 (major): detachClientViaCore cleared the attach marker unconditionally, even on `changed:false`. But `changed:false` is overloaded: probe-HAVING means "already clean" (safe to clear); probe-LESS means "cannot reverse, no probe to replay" (#212). The daemon reverse() KEEPS the marker for a probe-less descriptor; the CLI was dropping it, re-orphaning the settings the #212/#213 invariant protects. Gate the clearClientActionMarker call on `descriptor.attachProbe` so the CLI mirrors reverse(): clear for a probe-having client (changed:true or already-clean), keep for a probe-less one. Correct the comment (it claimed it behaved "exactly as reverse() does", which was wrong for the probe-less case). Finding 3 (minor): unit-test clearClientActionMarker's untested branches - missing file/bucket/key each return false and write no file; an emptied bucket is dropped while a sibling `backfill` bucket and its keys survive. Finding 4 (nit): cover the best-effort + changed:false semantics through the real CLI detach path - a probe-having client with already-clean settings still has its stale marker cleared; a probe-less client keeps its marker (the finding-2 guard, mirroring reverse()); a throwing retraction does not fail the detach. Mutation check: removing the `attachProbe` guard fails the probe-less test. Finding 5 (nit): replace a U+2014 em dash in test prose with a colon (AGENTS.md bans em dashes; @ref glosses excepted). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fdfdf69 commit 4a4d792

4 files changed

Lines changed: 591 additions & 0 deletions

File tree

src/core/cli/core_commands.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { discoverBundledPlugins } from '../runtime/bundled.js'
2222
import { isWithinDir } from '../runtime/contribution_names.js'
2323
import { buildPluginCatalog } from '../plugin_catalog.js'
2424
import { detachClientFromDisk } from '../config/client_detach_disk.js'
25+
import { clearClientActionMarker } from '../config/action_reconciler.js'
2526
import { configuredGatewayEndpoint } from '../config/gateway_endpoint.js'
2627
import { resolveClientSettingsPath } from '../daemon/client_settings_path.js'
2728
import { collectHypAwareStatus } from '../daemon/status.js'
@@ -3671,6 +3672,38 @@ async function detachClientViaCore({ name, descriptor, dryRun, json, ctx }) {
36713672
changed: true,
36723673
})
36733674
}
3675+
// Retract the attach marker so the CLI undo and the marker store stay in
3676+
// sync, mirroring the reconciler's reverse() after its own disk undo,
3677+
// including reverse()'s probe-less exception. `changed:false` is
3678+
// overloaded: for a probe-HAVING descriptor it means "settings already
3679+
// clean" (safe to clear a stale marker over them); for a probe-LESS
3680+
// descriptor it means "cannot reverse, no probe to replay" (#212). In
3681+
// that probe-less case reverse() KEEPS the marker (records a failed
3682+
// reverse) rather than orphaning the settings attach() wrote, so we gate
3683+
// on `attachProbe` and do the same: only a probe-having client
3684+
// (changed:true OR already-clean) has its marker cleared; a probe-less
3685+
// one keeps it. Without this retraction a manual detach reverses the
3686+
// settings but leaves an orphaned `done` attach marker, and the next
3687+
// `hyp join`'s forward gap short-circuits on it and never re-attaches the
3688+
// client (#217). Best-effort: a marker we cannot retract is a status
3689+
// blemish, not a detach failure (the settings undo already landed).
3690+
// @ref LLP 0045#part-3--reverse-runs-from-disk-the-marker-is-a-self-describing-undo-record [implements] — manual detach retracts its attach marker via the one core undo's store (probe-less keeps it, like reverse()), so CLI and reconciler reverse cannot drift (#212/#217)
3691+
if (descriptor.attachProbe) {
3692+
try {
3693+
clearClientActionMarker({
3694+
stateRoot: readObservabilityEnv(ctx.env).stateDir,
3695+
kind: 'attach',
3696+
requestKey: name,
3697+
})
3698+
} catch (markerErr) {
3699+
getLogger('cmd-detach').warn('client.detach.marker_retract_failed', {
3700+
hyp_client: name,
3701+
hyp_plugin: descriptor.plugin,
3702+
error_kind: 'marker_retract_failed',
3703+
detail: markerErr instanceof Error ? markerErr.message : String(markerErr),
3704+
})
3705+
}
3706+
}
36743707
writeCoreDetachOutput({ ctx, name, json, result })
36753708
} catch (err) {
36763709
span.setAttribute('status', 'failed')

src/core/config/action_reconciler.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,3 +329,46 @@ export function readClientActionStatus({ stateRoot }) {
329329
}
330330
return { byKind: store }
331331
}
332+
333+
/**
334+
* Retract a single client-action marker (`kind` + `requestKey`) from the store:
335+
* the write counterpart to {@link readClientActionStatus}, callable from any
336+
* process (the CLI is not the daemon, so it never constructs the reconciler).
337+
*
338+
* The manual `hyp detach` command calls it after a successful disk reversal so
339+
* the CLI undo and the marker store stay in sync, doing the same
340+
* `delete markers[requestKey]` the reconciler's `reverse()` does once its own
341+
* disk undo succeeds. Without it a manual detach reverses the on-disk settings
342+
* but leaves an orphaned `done` attach marker; the next join's forward gap then
343+
* short-circuits on that stale marker and never re-attaches the client (#217).
344+
* That is why detach-via-config-drop was rejoin-recoverable while detach-via-CLI
345+
* was not: this retraction is what makes the single core undo's two call sites
346+
* converge instead of drift.
347+
*
348+
* Atomic (tmp+rename, mode 0600), mirroring `writeStore`. A missing store, kind
349+
* bucket, or key is a no-op returning `false`; an emptied bucket is dropped so a
350+
* stale empty namespace never lingers, matching `reconcile()`'s own cleanup.
351+
*
352+
* @param {{ stateRoot: string, kind: string, requestKey: string, now?: () => number }} args
353+
* @returns {boolean} whether a marker was found and the store rewritten
354+
* @ref LLP 0045#part-3--reverse-runs-from-disk-the-marker-is-a-self-describing-undo-record [implements] — manual detach retracts its attach marker so the single core undo's two call sites (CLI + reconciler reverse) cannot drift; the marker never outlives its own effect being reversed (#217)
355+
*/
356+
export function clearClientActionMarker({ stateRoot, kind, requestKey, now = Date.now }) {
357+
const controlDir = path.join(stateRoot, CONTROL_DIRNAME)
358+
const markerPath = path.join(controlDir, CLIENT_ACTIONS_BASENAME)
359+
const store = readMarkerStore(markerPath)
360+
const markers = store[kind]
361+
if (!markers || !(requestKey in markers)) return false
362+
delete markers[requestKey]
363+
// Drop an emptied bucket so a no-op namespace never lingers (mirrors reconcile).
364+
if (Object.keys(markers).length > 0) {
365+
store[kind] = markers
366+
} else {
367+
delete store[kind]
368+
}
369+
fs.mkdirSync(controlDir, { recursive: true, mode: 0o700 })
370+
const tmp = `${markerPath}.tmp.${process.pid}.${now()}`
371+
fs.writeFileSync(tmp, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 })
372+
fs.renameSync(tmp, markerPath)
373+
return true
374+
}

test/core/action-reconciler.test.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ import path from 'node:path'
1010
import {
1111
createActionReconciler,
1212
readClientActionStatus,
13+
clearClientActionMarker,
1314
} from '../../src/core/config/action_reconciler.js'
1415
import { createAttachHandler } from '../../src/core/config/action_attach.js'
1516

1617
/**
1718
* @import {
1819
* ActionContext,
1920
* ActionHandler,
21+
* ActionMarkerStore,
2022
* ActionOutcome,
2123
* DesiredAction,
2224
* } from '../../src/core/config/types.d.ts'
@@ -444,3 +446,98 @@ test('a run-once handler never reverses a no-longer-desired done marker', async
444446
await fsp.rm(tmp, { recursive: true, force: true })
445447
}
446448
})
449+
450+
/**
451+
* Directly seed the client-actions marker store on disk (bypassing the
452+
* reconciler) so `clearClientActionMarker`'s retraction branches can be
453+
* exercised in isolation.
454+
*
455+
* @param {string} stateRoot
456+
* @param {ActionMarkerStore} store
457+
*/
458+
function seedMarkerFile(stateRoot, store) {
459+
const p = markerPath(stateRoot)
460+
fs.mkdirSync(path.dirname(p), { recursive: true })
461+
fs.writeFileSync(p, JSON.stringify(store, null, 2) + '\n')
462+
}
463+
464+
test('clearClientActionMarker is a no-op (returns false, writes nothing) when the file, bucket, or key is missing', async () => {
465+
const { tmp, stateRoot } = await makeFixture()
466+
try {
467+
// (a) Missing store file: nothing to retract, and none is created.
468+
assert.equal(
469+
clearClientActionMarker({ stateRoot, kind: 'attach', requestKey: 'claude' }),
470+
false,
471+
'missing marker file returns false'
472+
)
473+
assert.equal(
474+
fs.existsSync(markerPath(stateRoot)),
475+
false,
476+
'no marker file is written for a missing store'
477+
)
478+
479+
// (b) Missing bucket: an `attach` retraction over a store that only has a
480+
// `backfill` bucket leaves the file byte-for-byte unchanged.
481+
seedMarkerFile(stateRoot, {
482+
backfill: { '@hypaware/claude': { status: 'done', request_key: '@hypaware/claude' } },
483+
})
484+
const beforeBucket = fs.readFileSync(markerPath(stateRoot), 'utf8')
485+
assert.equal(
486+
clearClientActionMarker({ stateRoot, kind: 'attach', requestKey: 'claude' }),
487+
false,
488+
'missing bucket returns false'
489+
)
490+
assert.equal(
491+
fs.readFileSync(markerPath(stateRoot), 'utf8'),
492+
beforeBucket,
493+
'missing bucket does not rewrite the file'
494+
)
495+
496+
// (c) Missing key: the `attach` bucket exists but names a different client.
497+
seedMarkerFile(stateRoot, {
498+
attach: { codex: { status: 'done', request_key: 'codex' } },
499+
})
500+
const beforeKey = fs.readFileSync(markerPath(stateRoot), 'utf8')
501+
assert.equal(
502+
clearClientActionMarker({ stateRoot, kind: 'attach', requestKey: 'claude' }),
503+
false,
504+
'missing key returns false'
505+
)
506+
assert.equal(
507+
fs.readFileSync(markerPath(stateRoot), 'utf8'),
508+
beforeKey,
509+
'missing key does not rewrite the file'
510+
)
511+
} finally {
512+
await fsp.rm(tmp, { recursive: true, force: true })
513+
}
514+
})
515+
516+
test('clearClientActionMarker drops an emptied bucket but preserves sibling buckets and keys', async () => {
517+
const { tmp, stateRoot } = await makeFixture()
518+
try {
519+
// `attach` holds a single key; `backfill` is a sibling bucket with two.
520+
seedMarkerFile(stateRoot, {
521+
attach: { claude: { status: 'done', request_key: 'claude' } },
522+
backfill: {
523+
'@hypaware/claude': { status: 'done', request_key: '@hypaware/claude' },
524+
'@hypaware/codex': { status: 'done', request_key: '@hypaware/codex' },
525+
},
526+
})
527+
528+
assert.equal(
529+
clearClientActionMarker({ stateRoot, kind: 'attach', requestKey: 'claude' }),
530+
true,
531+
'retracting the last attach key returns true'
532+
)
533+
534+
const store = readMarkerFile(stateRoot)
535+
// Emptied bucket dropped entirely, not left dangling as an empty `{}`.
536+
assert.equal('attach' in store, false, 'the emptied attach bucket is dropped')
537+
// The sibling bucket and both its keys survive untouched.
538+
assert.equal(store.backfill['@hypaware/claude'].status, 'done', 'sibling backfill key survives')
539+
assert.equal(store.backfill['@hypaware/codex'].status, 'done', 'sibling backfill key survives')
540+
} finally {
541+
await fsp.rm(tmp, { recursive: true, force: true })
542+
}
543+
})

0 commit comments

Comments
 (0)