diff --git a/docs/skill-engine-seam.md b/docs/skill-engine-seam.md index 6593282d1..cb3089223 100644 --- a/docs/skill-engine-seam.md +++ b/docs/skill-engine-seam.md @@ -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 diff --git a/scripts/skill-apply.test.ts b/scripts/skill-apply.test.ts index 5504188f7..1886e227e 100644 --- a/scripts/skill-apply.test.ts +++ b/scripts/skill-apply.test.ts @@ -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[] = []; diff --git a/scripts/skill-apply.ts b/scripts/skill-apply.ts index e562df81b..c8b8d83ea 100644 --- a/scripts/skill-apply.ts +++ b/scripts/skill-apply.ts @@ -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 diff --git a/setup/lib/channels-remote.sh b/setup/lib/channels-remote.sh index eb67b8dd9..25a9ab970 100755 --- a/setup/lib/channels-remote.sh +++ b/setup/lib/channels-remote.sh @@ -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 ;;