Skip to content
Back to skills

Mission Loop Change

BSecurity

Change or add a mission loop safely — routing/lanes, schedule and cadence, driver code, plists, and adding a whole new mission. Use when editing tools/launchd/mission-control.sh, any dev.ailang.mission-* plist or mission-*.env, changing a mission's models/fallbacks/interval, porting a fix to the world fork, bootstrapping a new mission loop (e.g. for ailang-parse), or when asked why a mission-loop change "didn't take". Covers reach, live-edit hazards, drought simulation and reload.

  • 34 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 20, 2026
ai-agentspythonbashnodetestinggit

Security analysis

B75/100
  • criticalModifies startup scripts or system services for persistence

Pro shows the line behind each finding and how to fix it

Scanned September 27, 2026

npx -y skills add sunholo-data/ailang --skill mission-loop-change --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Mission Loop Change?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Mission Loop Change
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/sunholo-data-mission-loop-change/badge)](https://www.skillsdirectory.com/skills/sunholo-data-mission-loop-change)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: mission-loop-change
description: Change or add a mission loop safely — routing/lanes, schedule and cadence, driver code, plists, and adding a whole new mission. Use when editing tools/launchd/mission-control.sh, any dev.ailang.mission-* plist or mission-*.env, changing a mission's models/fallbacks/interval, porting a fix to the world fork, bootstrapping a new mission loop (e.g. for ailang-parse), or when asked why a mission-loop change "didn't take". Covers reach, live-edit hazards, drought simulation and reload.
---

# Changing a Mission Loop

The mission loops are four long-running agents on the rig. They are easy to *edit* and easy to
edit **without effect** — the recurring failure is not a broken change, it is a change that
lands somewhere nothing reads. Every rule here is a measured incident.

> **Read this first:** the friction is catalogued, with counts, in
> [`design_docs/planned/v0_36_0/m-mission-loop-workbench.md`](../../../design_docs/planned/v0_36_0/m-mission-loop-workbench.md).
> Once that ships, most of this file collapses into `ailang mission install|doctor`. Until then
> the procedure below **is** the control.

## Gate 0 — Which surface actually runs?

There are six places a mission's behaviour can be declared, and they are not kept in sync by
anything. Before editing, decide which one you need:

| Want to change | Edit | Reaches the fleet via |
|---|---|---|
| Driver logic (probes, gates, chains) | `tools/launchd/mission-control.sh` in **the ailang repo** | the driver pin — committed `origin/dev`, so **it must be pushed** |
| A mission's models / lanes / allowlist | `~/.config/ailang/mission-<name>.env` | sourced per fire. The repo copy under `tools/launchd/mission-env/` is **reviewable, not deployed** |
| Schedule / PATH / env | `~/Library/LaunchAgents/dev.ailang.mission-<name>.plist` | requires copy + reload. Installed plists are **copies, not symlinks** |
| Anything on `world` | the **`ailang-world` repo** | world runs a FORK and no pin — see Gate 2 |

**The trap, measured 2026-09-05:** the versioned `mission-docs.env` widened the planner
allowlist and its own comment declared itself "the sprint's only deployment surface". It was
never copied to `~/.config`. The docs mission has been routing that work to opus instead of
codex ever since, with a green CI arm — because the test asserts against the repo copy.

```bash
# ALWAYS check before and after editing an env file:
for m in v1 docs motoko world; do
  cmp -s tools/launchd/mission-env/mission-$m.env ~/.config/ailang/mission-$m.env \
    && echo "$m: in sync" || echo "$m: *** DIVERGED — the repo copy is not what runs"
done
```

## Gate 1 — Does the change reach the mission you mean?

A mission runs the driver its plist's `ProgramArguments` points at. Three of four re-exec from
the driver pin (committed `origin/dev` of *their own clone's* repo); world does not.

```bash
for m in v1:ailang docs:ailang-docs motoko:ailang-motoko world:ailang-world; do
  n=${m%%:*}; P=~/dev/sunholo-data/${m##*:}/tools/launchd/mission-control.sh
  printf "%-7s re-execs-from-pin=%s\n" "$n" \
    "$(grep -c '^\s*\. "\$REPO/tools/launchd/lib/pin-root.sh"' $P 2>/dev/null | sed 's/^0$/NO/;s/^[1-9].*/YES/')"
done
```

- `YES` → the clone's own contents are irrelevant; it runs committed `origin/dev`. **Push or it
  does not ship.** A "N behind" line about the source clone is expected and harmless.
- `NO` (world) → it runs the working tree directly. Committing is not enough and not required;
  the file on disk is what executes.

## Gate 2 — World is a fork

`world` lives in a separate GitHub repo with its own ~1000-line copy of the driver and no
`pin-root.sh`. It has silently missed every routing fix landed in `sunholo-data/ailang`.

- Any driver change you make for the fleet **must be made twice** until the de-fork lands.
- Port **verbatim**, then prove it:

```bash
for fn in _mc_set_controller select_model; do
  diff <(sed -n "/^${fn}()/,/^}/p" ~/dev/sunholo-data/ailang/tools/launchd/mission-control.sh) \
       <(sed -n "/^${fn}()/,/^}/p" ~/dev/sunholo-data/ailang-world/tools/launchd/mission-control.sh) \
    >/dev/null && echo "$fn: identical" || echo "$fn: DIFFERS"
done
```

Byte-identical is what lets you claim the upstream suite covers the port. Anything else and you
owe world its own tests.

## Gate 3 — Never edit a driver that is executing

World executes its driver straight from the working tree, and **bash reads a script
incrementally** — truncating it under a live interpreter feeds it garbage mid-iteration.

```bash
for f in ~/.ailang/state/mission-*.pid; do
  [ -f "$f" ] || continue; p=$(cat "$f")
  kill -0 "$p" 2>/dev/null && echo "RUNNING: $(basename $f) pid=$p"
done
```

If it is running, either wait, or write via **atomic replace** so the running process keeps its
inode (`python3 -c "..."` with `tempfile` + `os.replace`, or `sed > tmp && mv tmp file`).
`open(w)` and in-place `sed -i` are unsafe. The pinned missions are exempt — the pin runs from a
separate worktree.

## Gate 4 — Prove it, before it fires for real

**Nothing here spends tokens except where noted.** `MISSION_DRY_RUN=1` exits before the
iteration but *after* the probes, so it reports real resolved lanes.

```bash
# Working tree instead of the pinned copy — REQUIRED or you are testing the old driver.
# Use AILANG_DRIVER_PINNED, NOT AILANG_DRIVER_PIN=0: an env file that runs `export
# AILANG_DRIVER_PIN=1` (world's does) overrides the latter, the driver re-execs into the pin, and
# you silently test the old copy (measured 2026-09-26 — the old log line appeared in the output).
AILANG_DRIVER_PINNED=worktree-test MISSION_WORKDIR=<the mission's checkout> \
  MISSION_PROFILE=<name> MISSION_DRY_RUN=1 \
  /bin/bash tools/launchd/mission-control.sh 2>&1 | tail -3
```

**Simulate the Friday Anthropic drought instead of waiting for Friday.** Point a role at a model
that cannot answer; the pre-flights degrade it down its chain and `lanes=` reports the result:

```bash
AILANG_DRIVER_PIN=0 MISSION_PROFILE=<name> MISSION_DRY_RUN=1 \
  MISSION_DESIGNER_MODEL='claude:claude-drought-sim' \
  MISSION_EVALUATOR_MODEL='claude-drought-sim' \
  /bin/bash tools/launchd/mission-control.sh 2>&1 | grep -E 'anthropic|lanes='
# healthy -> lanes=ok, roles unchanged.  drought -> lanes=DEGRADED(n) with each handoff named.
```

Force the memory gate the same way (`MISSION_MIN_AVAIL_GB=9999 MISSION_MEM_WAIT=2`); it must log
`STILL SHORT … yield` and **never reach `DRY RUN ok`**. A gate you have only seen pass is a gate
you have not tested.

## Gate 5 — Config declared is not config walked

A `MISSION_<ROLE>_FALLBACK` only does something if a pre-flight loop walks that role **and**
matches the value's provider prefix. `MISSION_EVALUATOR_FALLBACK` sat dead for ten days because
the loops iterated `PLANNER EXECUTOR` and the skill reads no `*_FALLBACK` var at all.

Before believing a role survives an outage, check all three:

```bash
grep -n 'for role in' tools/launchd/mission-control.sh        # is the role in the list?
grep -n 'case "$val" in' tools/launchd/mission-control.sh     # does an arm match its prefix?
grep -c 'MISSION_.*_FALLBACK' .claude/skills/mission-control/SKILL.md   # 0 = the skill reads none
```

Then prove it with the drought simulation above. Do not reason about it.

## Gate 6 — Tests, and the ones that do not run

```bash
make test-launchd-drivers        # must be exit 0; bash 3.2, the rig has no 4.x
```

The suites are grep-based structural assertions over `$HERE/mission-control.sh`, so **copying a
suite into another repo tests that repo's driver** — that is how world gets coverage today.

Two suites were orphaned (present but absent from `make/test.mk`) and CI never ran them. When
you add a suite, wire it, and check nothing else has drifted out:

```bash
for f in tools/launchd/test_*.sh; do
  grep -q "$(basename $f)" make/test.mk || echo "ORPHAN (CI never runs): $(basename $f)"
done
```

## Gate 7 — Installing and reloading

```bash
cp tools/launchd/dev.ailang.mission-<name>.plist ~/Library/LaunchAgents/
plutil -lint ~/Library/LaunchAgents/dev.ailang.mission-<name>.plist   # never skip
launchctl bootout   "gui/$(id -u)/dev.ailang.mission-<name>"
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/dev.ailang.mission-<name>.plist
launchctl print     "gui/$(id -u)/dev.ailang.mission-<name>" | grep -E 'state|properties'
```

- **Check Gate 3 first** — bootout kills a running iteration.
- `RunAtLoad` means bootstrap starts an iteration immediately. Expect it.
- `rig-watchdog` re-bootstraps missing jobs every 60s, so a bare `bootout` does not stick. To
  hold a mission off you need the kill switch (`~/.ailang/state/mission-<name>.disabled`), not
  `bootout`.
- Keep the plist **versioned in the repo** and copy from there. World's lived only in
  LaunchAgents for months, so its schedule was unreviewable.

## Scheduling: what the knobs actually do

`StartInterval` **re-arms from the job's EXIT**, not its start — measured across four gaps on two
missions (5403/5372/5379s against 5400s; 14390s against 14400s). So:

```
StartInterval:                  period = duration + interval   (a full interval idle, every cycle)
KeepAlive + ThrottleInterval:   period = max(duration, throttle)
```

`ThrottleInterval` is measured from the **start**, which is what makes it a floor rather than a
gap. It is load-bearing under `KeepAlive`: the yield paths (kill switch, overlap guard, memory
gate) exit in seconds, and bare `KeepAlive` would respawn them in a tight loop.

**Cadence is a measured decision, not a preference.** On 2026-09-02 three loops were tightened in
the same hour: fleet stall rate 6% → 33%, and total starts did **not** rise. Change one loop,
hold the others, and re-read the stall rate the next day — signature `claude idle with a
descendant alive >=2400s`. Durations and verdicts are in
`~/.ailang/state/mission-<name>-slot-verdicts.log` (durable; `/tmp` driver logs do not survive a
reboot).

## Adding a whole new mission

**Follow `docs/docs/guides/mission-bootstrap.md`** (Steps 3.6 and 4 were rewritten for the registry
on 2026-09-26). The registry exists now: `missions/<name>.toml`, then `ailang mission install <name>`
(renders for review) and `ailang mission apply <name>` (installs and loads), run from the MAIN
checkout. Worked example: the fleet mission (M-HARNESS-MISSION-LOOP). The older manual path is
kept below for reference only:

1. `sed s/__NAME__/<name>/g < tools/launchd/mission-template.plist > ~/Library/LaunchAgents/dev.ailang.mission-<name>.plist`
   — and **commit a copy to the repo**.
2. Write `~/.config/ailang/mission-<name>.env` (`MISSION_NAME`, `MISSION_REPO`, `MISSION_DOC`,
   `MISSION_WORKDIR`) **and** the reviewable copy under `tools/launchd/mission-env/`. Keep them
   identical (Gate 0).
3. Seed `~/.ailang/state/mission-<name>-gh-issue` with the bookkeeping issue number.
4. Add a boot-stagger offset arm for the name in `_mc_boot_offset` — an unknown mission silently
   gets 0 and joins the boot stampede.
5. Give the plist a PATH that includes `/usr/sbin`, or rely on the driver's own append. Omitting
   it disabled the boot stagger on two missions.
6. Dry-run it (Gate 4) **before** bootstrapping, then Gate 7.

Prefer running the new mission's driver from a clone that sources `pin-root.sh`. A fork is how
world ended up invisible.

## Harness work belongs to the fleet mission (2026-09-26)

Product loops no longer change the loop harness: the driver enables a per-fire pre-push scope
guard (`tools/launchd/githooks/pre-push`), and they file `ailang mission ticket file` instead. The
`fleet` mission works those tickets, and this skill's pre-flight is its done-gate. An attended
session (no `MISSION_NAME`) is unaffected and can still make changes like these directly.

## The five-line pre-flight

Before claiming a mission-loop change is done:

- [ ] Edited the surface that actually runs (Gate 0), and repo/installed copies match
- [ ] Confirmed reach — pushed if pinned; on disk if not (Gate 1); ported to world if fleet-wide (Gate 2)
- [ ] Dry-ran healthy **and** degraded (Gate 4); any new gate proven non-vacuous
- [ ] `make test-launchd-drivers` exit 0, new suites wired (Gate 6)
- [ ] Reloaded and verified state, with nothing mid-iteration (Gates 3, 7)

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.