fix(setup): live-smoke fixes — anchor the channels-remote glob; silence operator blocks once blocked

Two bugs the first live wizard run surfaced:

- channels-remote.sh's unanchored `*qwibitai/nanoclaw*` glob matched sibling
  channel repos (qwibitai/nanoclaw-discord sorts first on multi-remote
  machines) and resolved the from-branch copy to a remote with no channels
  ref, failing every from-branch fetch. The pattern now anchors the repo-name
  tail (nanoclaw / nanoclaw.git only).

- Once the run-health gate latched `blocked`, later nc:operator blocks still
  rendered — walking the human through "a pairing code is about to appear"
  and a readiness confirm for a step the engine had already gated. Blocked
  runs now skip operator directives: no event (so no URL offer / readiness
  confirm), no operatorMessages entry, recorded in skipped. Spec §2 updated.

Suite 722 passed | 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-07-04 18:05:59 +03:00
parent c02c002caa
commit 0c020e664d
4 changed files with 74 additions and 1 deletions
+6
View File
@@ -115,6 +115,12 @@ can derive its own.
`res.operatorMessages`, then `await onEvent({type:'operator', …})` before
evaluating the next directive. An unresolved `{{var}}` in the body defers the
whole block before any event fires (today's behavior, `skill-apply.ts:787`).
**Once the run is `blocked`** (an earlier bounce), operator directives are
skipped instead — no event, no `operatorMessages` entry, recorded in
`skipped`. Walking the human through steps whose side effects the run has
already gated ("a pairing code is about to appear" → nothing appears) is
actively misleading, and a failed run's manual-steps report must not include
steps predicated on the failure.
3. Every `onEvent` call is awaited; a rejection from `onEvent` is treated like
any other throw at that directive (bounce, not crash). This applies to
operator events too: a consumer that throws from `onEvent` **accepts the
+53
View File
@@ -762,6 +762,59 @@ describe('run-health gate (a bounce blocks later side effects)', () => {
expect(gated).toHaveLength(2); // restart + step, both bounced by the gate
});
// Once blocked, an operator block must not walk the human through steps the
// run has already gated ("a pairing code is about to appear" → nothing
// appears). No event ⇒ a consumer's URL offer / readiness confirm never
// fires; no operatorMessages entry ⇒ a failed run's manual-steps report
// omits steps predicated on the failure. A block BEFORE the failure still
// renders normally.
it('a bounce also silences later operator blocks — no event, no collected message', async () => {
writeFileSync(
join(gskill, 'SKILL.md'),
[
'# doomed walkthrough demo',
'',
'## Create the bot',
'```nc:operator',
'Make the bot first.',
'```',
'',
'## Validate the credential',
'```nc:run capture:who effect:fetch',
'verify-cred',
'```',
'',
'## Get ready to pair',
'```nc:operator',
'Open the bot — a pairing code is about to appear.',
'```',
'',
'## Pair',
'```nc:run effect:step capture:platform_id=PLATFORM_ID',
'run-pair-step',
'```',
'',
].join('\n'),
);
const events: string[] = [];
const res = await applySkill(gskill, groot, {
inputs: {},
exec: (c) => {
if (c === 'verify-cred') throw new Error('401 bad credential');
},
execStream: async () => ({ ok: true, fields: { PLATFORM_ID: 'x' } }),
onEvent: (e) => {
if (e.type === 'operator') events.push(e.text);
},
});
expect(events).toEqual(['Make the bot first.']); // pre-failure block rendered…
expect(res.operatorMessages).toEqual(['Make the bot first.']); // …and collected
expect(res.operatorMessages.join()).not.toContain('about to appear'); // doomed block silenced
expect(res.skipped).toContain('operator: skipped after an earlier failure');
expect(res.agentTasks.some((t) => /an earlier step did not complete/.test(t.reason))).toBe(true); // step still gated
});
it('a deferred prompt does NOT block a later restart (headless rebuild stays runnable)', async () => {
writeFileSync(join(gskill, 'SKILL.md'), DEFER_THEN_RESTART_SKILL);
const cmds: string[] = [];
+10
View File
@@ -768,6 +768,16 @@ export async function applySkill(skillDir: string, root: string, opts: ApplyOpti
continue;
}
if (d.kind === 'operator') {
// Once the run is blocked, walking the human through further manual
// steps is actively misleading — the side effects those instructions
// lead up to ("a pairing code is about to appear") have already been
// gated. Skip: no event (so a consumer's URL offer / readiness confirm
// never fires), no operatorMessages entry (a failed run's manual-steps
// report must not include steps predicated on the failed one).
if (blocked) {
res.skipped.push('operator: skipped after an earlier failure');
continue;
}
// Always collect the human-facing instructions into the result so a
// programmatic caller can relay/output them. {{vars}} render so a
// resolved value can be shown (throws → deferred if a referenced var is
+5 -1
View File
@@ -20,10 +20,14 @@ resolve_channels_remote() {
return 0
fi
# Anchor the repo-name tail: the pattern must match the nanoclaw repo itself,
# never a sibling channel repo like qwibitai/nanoclaw-discord (which carries
# no channels branch — an unanchored `*nanoclaw*` glob picked those on
# multi-remote machines and broke every from-branch copy).
local remote url
while IFS=$'\t' read -r remote url; do
case "$url" in
*qwibitai/nanoclaw*|*nanocoai/nanoclaw*)
*qwibitai/nanoclaw|*qwibitai/nanoclaw.git|*nanocoai/nanoclaw|*nanocoai/nanoclaw.git)
printf '%s' "$remote"
return 0
;;