feat(skills): effect:restart directive — phasing is just directive order

The install→restart→wire "phases" aren't a driver concern; they're directives in
document order. Restart is a run, placed between the install/credentials and the
wiring, and the engine already runs directives top-to-bottom — so no phase
orchestration is needed.

- effect:restart: a run whose body restarts the service and waits for the ncl
  socket so a following wiring directive doesn't race it. New helper
  setup/lib/restart.sh (platform-aware restart + best-effort socket wait; the
  engine execs each body line separately, so one self-contained script).
- ApplyOptions.skipEffects: run effects the CALLER owns and performs itself are
  skipped (not executed). A standalone /add-slack runs the restart; a headless
  rebuild or a setup that restarts once at the end passes ['restart'] and it's
  skipped. The declarative form of applyProviderSkill's isFlowOwnedCommand.

add-slack now carries the restart between Credentials and the wire, so the whole
flow is one self-contained ordered directive sequence. Dry-run: standalone runs
the restart, a flow caller skips it — both fully apply, both wire. 604 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-06-27 17:11:35 +03:00
parent 3eca5588f9
commit f629ff25fa
6 changed files with 64 additions and 4 deletions
+9
View File
@@ -101,6 +101,15 @@ Set up event delivery (needs a public HTTPS URL for port 3000 — ngrok, a Cloud
3. Interactivity & Shortcuts → toggle Interactivity on, set the same Request URL, Save Changes, then reinstall the app when Slack prompts.
```
## Restart
Restart the service so it loads the Slack adapter and the credentials you just
stored, and wait for its CLI socket before wiring:
```nc:run effect:restart
bash setup/lib/restart.sh
```
## Connect yourself
Wire your own Slack account as the owner so you can talk to the assistant, and
+13
View File
@@ -429,4 +429,17 @@ describe('programmatic apply via inputs', () => {
expect(env).toContain('B=fromPrompter'); // prompter filled the gap
expect(asked).toEqual(['b']); // 'a' was never asked — it came from inputs
});
it('skipEffects skips a run the caller owns (effect:restart) but runs the rest', async () => {
writeFileSync(
join(pskill, 'SKILL.md'),
'# restart demo\n\n```nc:run effect:build\npnpm run build\n```\n```nc:run effect:restart\nbash setup/lib/restart.sh\n```\n```nc:run effect:wire\nncl wire\n```\n',
);
const cmds: string[] = [];
const res = await applySkill(pskill, proot, { inputs: {}, skipEffects: ['restart'], exec: (c) => void cmds.push(c) });
expect(cmds).toContain('pnpm run build');
expect(cmds).toContain('ncl wire');
expect(cmds).not.toContain('bash setup/lib/restart.sh'); // restart owned by the caller → skipped
expect(res.skipped.some((s) => /run restart: owned by the caller/.test(s))).toBe(true);
});
});
+9
View File
@@ -205,6 +205,10 @@ export interface ApplyOptions {
// dep/run/branch-fetch; injectable for tests. Returns the command's stdout so
// a `run capture:<var>` can bind it into a {{var}} (the twin of `prompt`).
exec?: (cmd: string) => string | void | Promise<string | void>;
// Run effects the CALLER owns and will perform itself — those runs are skipped
// (not executed). e.g. a headless rebuild or a setup that restarts once at the
// end passes ['restart']; applyProviderSkill passes ['build','test'].
skipEffects?: string[];
// Resolve which remote carries a `from-branch` registry branch. Defaults to a
// generic resolver (env override → first remote that has the branch → origin);
// setup injects one that reuses setup/lib/channels-remote.sh for exact parity.
@@ -411,6 +415,11 @@ export async function applySkill(skillDir: string, root: string, opts: ApplyOpti
res.applied.push(`operator: ${(d.body[0] ?? '').slice(0, 50)}`);
continue;
}
// A run whose effect the caller owns (e.g. restart) is skipped here.
if (d.kind === 'run' && typeof d.attrs.effect === 'string' && opts.skipEffects?.includes(d.attrs.effect)) {
res.skipped.push(`run ${d.attrs.effect}: owned by the caller`);
continue;
}
const st = selfStatus(d, root);
if (st.status === 'agent') { bounce(d, 'no deterministic handler'); continue; }
if (st.status === 'skip') { res.skipped.push(`${d.kind}: ${st.detail}`); continue; }
+2
View File
@@ -21,6 +21,7 @@ describe('skill-directives parser, on the converted add-slack', () => {
'env-set', // credentials: write captured values to .env
'env-sync', // credentials: sync to container
'operator', // credentials: event-delivery walkthrough
'run', // restart: load the adapter + creds, wait for the CLI socket
'prompt', // wire: owner member id
'prompt', // wire: target agent folder
'run', // wire: validate token (auth.test)
@@ -58,6 +59,7 @@ describe('skill-directives parser, on the converted add-slack', () => {
expect(directives.filter((d) => d.kind === 'run').map((d) => d.attrs.effect)).toEqual([
'build',
'test',
'restart', // load adapter + creds before wiring
'fetch', // validate: auth.test
'fetch', // resolve: conversations.open
'wire', // ncl wiring
+6 -4
View File
@@ -20,12 +20,14 @@
// copy [from-branch:<b>] body: `PATH` (src==dst) or `SRC -> DST` overwrite
// append to:<file> [at:<marker>] body: line(s) to add skip if present
// dep [manager:pnpm] body: `pkg@<exact-semver>` line(s) reinstall no-op
// run [effect:build|test|fetch|external|wire] [capture:<var>] re-runnable
// run [effect:build|test|fetch|external|wire|restart] [capture:<var>] re-runnable
// body: shell command(s). {{vars}} are substituted in. effect:wire runs
// `ncl …` to wire collected input (no undo — the rows it creates are user
// runtime data, not reversed on skill remove). capture:<var> binds the
// command's stdout into {{var}} (twin of prompt) — e.g. resolve an id
// from an API and feed it to a later directive.
// runtime data, not reversed on skill remove). effect:restart restarts the
// service so following `ncl` runs reach it; a caller that owns the restart
// (rebuild, or a setup that restarts once) skips it via ApplyOptions.
// skipEffects. capture:<var> binds the command's stdout into {{var}} (twin
// of prompt) — e.g. resolve an id from an API and feed it to a later step.
// prompt <var> [secret] body: the question → binds {{var}} skip if satisfied
// operator body: instructions for the human operator output-only
// The SKILL.md is addressed to the coding agent; `operator` delineates the
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Restart the NanoClaw service, then wait (best-effort) for its `ncl` CLI socket
# so a following wiring directive doesn't race the restart. Channel skills call
# this as `nc:run effect:restart`. Best-effort throughout: a fresh setup may not
# have the service installed yet, and the wiring's own `ncl` call is the real
# signal if the socket never appears — so a wait timeout does not fail the step.
set -u
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/../.." && pwd)"
# shellcheck source=/dev/null
source "$here/install-slug.sh"
case "$(uname -s)" in
Darwin) launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" 2>/dev/null || true ;;
Linux) systemctl --user restart "$(systemd_unit)" 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" 2>/dev/null || true ;;
esac
# Wait up to ~30s for the CLI socket so `ncl` can connect on the next directive.
for _ in $(seq 1 60); do
[ -S "$root/data/ncl.sock" ] && exit 0
sleep 0.5
done
echo "nanoclaw: ncl socket not up yet after restart — the wiring step may need a retry" >&2
exit 0