@zerwiz/ymir 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/README.md +291 -0
- package/.agents/agents/bragi-marketer.md +111 -0
- package/.agents/agents/brokk.md +352 -0
- package/.agents/agents/forseti-reviewer.md +93 -0
- package/.agents/agents/frigg-consent.md +77 -0
- package/.agents/agents/groa-update.md +77 -0
- package/.agents/agents/hnoss-designer.md +69 -0
- package/.agents/agents/huginn-researcher.md +99 -0
- package/.agents/agents/jord-projects.md +78 -0
- package/.agents/agents/kvasir-scout.md +91 -0
- package/.agents/agents/mimir-planner.md +92 -0
- package/.agents/agents/muninn-stow.md +77 -0
- package/.agents/agents/saga-bearings.md +78 -0
- package/.agents/agents/sindri-developer.md +114 -0
- package/.agents/agents/snotra-documenter.md +105 -0
- package/.agents/agents/syn-recovery.md +78 -0
- package/.agents/agents/tyr-check.md +78 -0
- package/.agents/agents/urdh-hold.md +77 -0
- package/.agents/agents/volundr-smith.md +80 -0
- package/.agents/agents/vor-diagnostics.md +77 -0
- package/.agents/assets/agents/naming.md +50 -0
- package/.agents/assets/agents/registry.md +93 -0
- package/.agents/assets/agents/runtime.md +36 -0
- package/.agents/assets/agents/toon-tasks-cli.md +42 -0
- package/.agents/assets/templates/company_entity.template.md +37 -0
- package/.agents/backend/README.md +74 -0
- package/.agents/backend/backends/cmux.sh +649 -0
- package/.agents/backend/backends/herdr-eventwait.py +157 -0
- package/.agents/backend/backends/herdr-workspace-move.py +114 -0
- package/.agents/backend/backends/herdr.sh +3386 -0
- package/.agents/backend/backends/orca.sh +290 -0
- package/.agents/backend/backends/tmux.sh +357 -0
- package/.agents/backend/backends/zellij.sh +700 -0
- package/.agents/backend/fm-afk-launch.sh +654 -0
- package/.agents/backend/fm-afk-return.sh +241 -0
- package/.agents/backend/fm-afk-start.sh +172 -0
- package/.agents/backend/fm-arm-command-policy.mjs +960 -0
- package/.agents/backend/fm-arm-pretool-check.sh +200 -0
- package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
- package/.agents/backend/fm-backend.sh +981 -0
- package/.agents/backend/fm-backlog-handoff.sh +909 -0
- package/.agents/backend/fm-backlog-receive.sh +187 -0
- package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
- package/.agents/backend/fm-bearings-board.sh +204 -0
- package/.agents/backend/fm-bearings-snapshot.sh +546 -0
- package/.agents/backend/fm-bootstrap.sh +1568 -0
- package/.agents/backend/fm-branch-outcome.sh +203 -0
- package/.agents/backend/fm-branch-prompt.sh +101 -0
- package/.agents/backend/fm-brief.sh +452 -0
- package/.agents/backend/fm-busy-event.sh +234 -0
- package/.agents/backend/fm-busy-lib.sh +986 -0
- package/.agents/backend/fm-captain-hold.sh +1001 -0
- package/.agents/backend/fm-cd-command-policy.mjs +153 -0
- package/.agents/backend/fm-cd-pretool-check.sh +190 -0
- package/.agents/backend/fm-check-lib.sh +74 -0
- package/.agents/backend/fm-check-register.sh +42 -0
- package/.agents/backend/fm-check-unregister.sh +52 -0
- package/.agents/backend/fm-classify-lib.sh +1770 -0
- package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
- package/.agents/backend/fm-composer-lib.sh +1416 -0
- package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
- package/.agents/backend/fm-config-push.sh +254 -0
- package/.agents/backend/fm-control-lib.sh +251 -0
- package/.agents/backend/fm-control.sh +878 -0
- package/.agents/backend/fm-crew-state.sh +625 -0
- package/.agents/backend/fm-cursor-lib.sh +243 -0
- package/.agents/backend/fm-decision-hold.sh +232 -0
- package/.agents/backend/fm-doc-audience-check.sh +269 -0
- package/.agents/backend/fm-dod-lib.sh +67 -0
- package/.agents/backend/fm-ensure-agents-md.sh +253 -0
- package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
- package/.agents/backend/fm-extension.mjs +2577 -0
- package/.agents/backend/fm-extension.sh +16 -0
- package/.agents/backend/fm-ff-lib.sh +420 -0
- package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
- package/.agents/backend/fm-fleet-sync.sh +458 -0
- package/.agents/backend/fm-fleet-view.sh +96 -0
- package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
- package/.agents/backend/fm-guard.sh +242 -0
- package/.agents/backend/fm-harness.sh +195 -0
- package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
- package/.agents/backend/fm-herdr-lab.sh +346 -0
- package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
- package/.agents/backend/fm-home-seed.sh +984 -0
- package/.agents/backend/fm-home-summary-refresh.sh +255 -0
- package/.agents/backend/fm-hook-host-lib.sh +36 -0
- package/.agents/backend/fm-inactive-reconcile.sh +531 -0
- package/.agents/backend/fm-inbox.sh +399 -0
- package/.agents/backend/fm-install-actionlint.sh +84 -0
- package/.agents/backend/fm-install-herdr.sh +96 -0
- package/.agents/backend/fm-install-shellcheck.sh +84 -0
- package/.agents/backend/fm-install-treehouse.sh +95 -0
- package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
- package/.agents/backend/fm-lease-lib.sh +218 -0
- package/.agents/backend/fm-lease.sh +189 -0
- package/.agents/backend/fm-line-cap-lib.sh +51 -0
- package/.agents/backend/fm-lint-workflows.sh +137 -0
- package/.agents/backend/fm-lint.sh +591 -0
- package/.agents/backend/fm-lock-lib.sh +104 -0
- package/.agents/backend/fm-lock.sh +107 -0
- package/.agents/backend/fm-marker-lib.sh +12 -0
- package/.agents/backend/fm-merge-local.sh +74 -0
- package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
- package/.agents/backend/fm-nm-run-lib.sh +124 -0
- package/.agents/backend/fm-on.sh +125 -0
- package/.agents/backend/fm-operational-input.sh +253 -0
- package/.agents/backend/fm-peek.sh +46 -0
- package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
- package/.agents/backend/fm-pr-check.sh +135 -0
- package/.agents/backend/fm-pr-lib.sh +1016 -0
- package/.agents/backend/fm-pr-merge.sh +707 -0
- package/.agents/backend/fm-pr-poll.sh +110 -0
- package/.agents/backend/fm-primary-scope-lib.sh +33 -0
- package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
- package/.agents/backend/fm-procevent-lavish.sh +628 -0
- package/.agents/backend/fm-procevent-lib.sh +892 -0
- package/.agents/backend/fm-procevent-quota.sh +290 -0
- package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
- package/.agents/backend/fm-procevent-when.sh +504 -0
- package/.agents/backend/fm-procevent.sh +1466 -0
- package/.agents/backend/fm-project-mode.sh +92 -0
- package/.agents/backend/fm-project-origin-lib.sh +180 -0
- package/.agents/backend/fm-promote.sh +282 -0
- package/.agents/backend/fm-public-followup-emit.sh +260 -0
- package/.agents/backend/fm-public-followup-lib.sh +398 -0
- package/.agents/backend/fm-public-followup.sh +1330 -0
- package/.agents/backend/fm-push-transition-lib.sh +168 -0
- package/.agents/backend/fm-quota-axi-lib.sh +93 -0
- package/.agents/backend/fm-quota-choose.sh +384 -0
- package/.agents/backend/fm-remote-delta-read.sh +189 -0
- package/.agents/backend/fm-remote-doctor.sh +800 -0
- package/.agents/backend/fm-remote-entrypoint.sh +193 -0
- package/.agents/backend/fm-remote-file.sh +234 -0
- package/.agents/backend/fm-remote-home-provision.sh +259 -0
- package/.agents/backend/fm-remote-home-seed.sh +259 -0
- package/.agents/backend/fm-remote-inherit-push.sh +90 -0
- package/.agents/backend/fm-remote-inherit.sh +179 -0
- package/.agents/backend/fm-remote-job-lib.sh +1241 -0
- package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
- package/.agents/backend/fm-remote-job-worker.sh +1124 -0
- package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
- package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
- package/.agents/backend/fm-review-diff.sh +158 -0
- package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
- package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
- package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
- package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
- package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
- package/.agents/backend/fm-secondmate-report.sh +88 -0
- package/.agents/backend/fm-send.sh +1059 -0
- package/.agents/backend/fm-session-lock-lib.sh +176 -0
- package/.agents/backend/fm-session-start.sh +970 -0
- package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
- package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
- package/.agents/backend/fm-sessionstart-run.sh +145 -0
- package/.agents/backend/fm-spawn.sh +3153 -0
- package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
- package/.agents/backend/fm-startup-memory-budget.sh +94 -0
- package/.agents/backend/fm-startup-network.sh +642 -0
- package/.agents/backend/fm-stow-cascade.sh +251 -0
- package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
- package/.agents/backend/fm-supervise-daemon.sh +1732 -0
- package/.agents/backend/fm-supervision-instructions.sh +215 -0
- package/.agents/backend/fm-supervision-lib.sh +90 -0
- package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
- package/.agents/backend/fm-tangle-lib.sh +53 -0
- package/.agents/backend/fm-task-inbox-lib.sh +402 -0
- package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
- package/.agents/backend/fm-teardown.sh +2922 -0
- package/.agents/backend/fm-test-isolation-proof.sh +588 -0
- package/.agents/backend/fm-test-run.sh +2191 -0
- package/.agents/backend/fm-timeout-lib.sh +141 -0
- package/.agents/backend/fm-timing-lib.sh +179 -0
- package/.agents/backend/fm-tmux-lib.sh +291 -0
- package/.agents/backend/fm-tool-update-check.sh +898 -0
- package/.agents/backend/fm-trace-context-lib.sh +227 -0
- package/.agents/backend/fm-transition-lib.sh +103 -0
- package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
- package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
- package/.agents/backend/fm-turnend-guard.sh +432 -0
- package/.agents/backend/fm-update.sh +110 -0
- package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
- package/.agents/backend/fm-voice-client.py +1373 -0
- package/.agents/backend/fm-voice-relay.py +1256 -0
- package/.agents/backend/fm-wake-drain.sh +592 -0
- package/.agents/backend/fm-wake-grant.sh +127 -0
- package/.agents/backend/fm-wake-lib.sh +1849 -0
- package/.agents/backend/fm-watch-arm.sh +602 -0
- package/.agents/backend/fm-watch-checkpoint.sh +109 -0
- package/.agents/backend/fm-watch.sh +1962 -0
- package/.agents/backend/fm-x-dismiss.sh +116 -0
- package/.agents/backend/fm-x-followup.sh +286 -0
- package/.agents/backend/fm-x-lib.sh +1000 -0
- package/.agents/backend/fm-x-link.sh +234 -0
- package/.agents/backend/fm-x-poll.sh +185 -0
- package/.agents/backend/fm-x-reply.sh +385 -0
- package/.agents/backend/fm_voice_frame.py +166 -0
- package/.agents/backend/fm_voice_records.py +574 -0
- package/.agents/backend/model-bridge.py +360 -0
- package/.agents/backend/opencode-go-bridge.py +30 -0
- package/.agents/bus/README.md +12 -0
- package/.agents/bus/messages.json +1 -0
- package/.agents/config/agents.machine.example.yaml +16 -0
- package/.agents/config/agents.yaml.example +72 -0
- package/.agents/config/crew-dispatch.json +22 -0
- package/.agents/config/cron.yaml +11 -0
- package/.agents/config/eindri-dispatch.json +67 -0
- package/.agents/config/eindri-harness +1 -0
- package/.agents/config/startup-memory-budget +1 -0
- package/.agents/filebrowser/.filebrowser.json +6 -0
- package/.agents/filebrowser/README.md +11 -0
- package/.agents/gateway/README.md +12 -0
- package/.agents/github/README.md +15 -0
- package/.agents/github/webhooks/.gitkeep +0 -0
- package/.agents/github/workflows/.gitkeep +0 -0
- package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
- package/.agents/harness/opencode/plugins/package.json +4 -0
- package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
- package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
- package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
- package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
- package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
- package/.agents/memory/README.md +46 -0
- package/.agents/memory/runes_audit.md +5 -0
- package/.agents/migrations/0001-hodd-layout.sh +38 -0
- package/.agents/migrations/0002-a2a-mcp.sh +27 -0
- package/.agents/migrations/0003-private-data-separation.sh +121 -0
- package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
- package/.agents/migrations/0005-roots-out-of-tree.sh +158 -0
- package/.agents/sandbox/Dockerfile.utgard +19 -0
- package/.agents/sandbox/README.md +14 -0
- package/.agents/sandbox/utgard.config.json +14 -0
- package/.agents/skills/README.md +59 -0
- package/.agents/skills/bragi-marketing/README.md +3 -0
- package/.agents/skills/bragi-marketing/SKILL.md +82 -0
- package/.agents/skills/eindri-homes/README.md +3 -0
- package/.agents/skills/eindri-homes/SKILL.md +291 -0
- package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
- package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
- package/.agents/skills/frigg-consent/README.md +3 -0
- package/.agents/skills/frigg-consent/SKILL.md +56 -0
- package/.agents/skills/galdr-ymirsystem/README.md +3 -0
- package/.agents/skills/galdr-ymirsystem/SKILL.md +159 -0
- package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
- package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
- package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
- package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
- package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
- package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +736 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
- package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +539 -0
- package/.agents/skills/galdr-ymirsystem/assets/installation.md +763 -0
- package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
- package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
- package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +163 -0
- package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +415 -0
- package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
- package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +133 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +101 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
- package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
- package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
- package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
- package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
- package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
- package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
- package/.agents/skills/galdr-ymirsystem/assets/smidja.md +180 -0
- package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
- package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
- package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
- package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +503 -0
- package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
- package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
- package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
- package/.agents/skills/gjallarhorn-relay/README.md +3 -0
- package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
- package/.agents/skills/groa-update/SKILL.md +90 -0
- package/.agents/skills/hamr-adapters/README.md +3 -0
- package/.agents/skills/hamr-adapters/SKILL.md +123 -0
- package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
- package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
- package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
- package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
- package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
- package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
- package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
- package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
- package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
- package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
- package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
- package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
- package/.agents/skills/herdr-panes/README.md +3 -0
- package/.agents/skills/herdr-panes/SKILL.md +92 -0
- package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
- package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
- package/.agents/skills/hnoss-design/DESIGN.md +19 -0
- package/.agents/skills/hnoss-design/README.md +3 -0
- package/.agents/skills/hnoss-design/SKILL.md +83 -0
- package/.agents/skills/hvild-afk/README.md +3 -0
- package/.agents/skills/hvild-afk/SKILL.md +241 -0
- package/.agents/skills/jord-projects/README.md +3 -0
- package/.agents/skills/jord-projects/SKILL.md +92 -0
- package/.agents/skills/lifecycle/SKILL.md +56 -0
- package/.agents/skills/lifecycle/smoke_test.sh +57 -0
- package/.agents/skills/lifecycle/start.sh +21 -0
- package/.agents/skills/lifecycle/status.sh +35 -0
- package/.agents/skills/lifecycle/stop.sh +16 -0
- package/.agents/skills/muninn-stow/README.md +3 -0
- package/.agents/skills/muninn-stow/SKILL.md +309 -0
- package/.agents/skills/no-mistakes/SKILL.md +379 -0
- package/.agents/skills/nornir-schedule/README.md +3 -0
- package/.agents/skills/nornir-schedule/SKILL.md +25 -0
- package/.agents/skills/nornir-schedule/assets/events.md +137 -0
- package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
- package/.agents/skills/nsr-compliance/README.md +3 -0
- package/.agents/skills/nsr-compliance/SKILL.md +29 -0
- package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
- package/.agents/skills/pr-ops/README.md +3 -0
- package/.agents/skills/pr-ops/SKILL.md +109 -0
- package/.agents/skills/ratatoskr-a2a/README.md +3 -0
- package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
- package/.agents/skills/rules-check-drift/SKILL.md +81 -0
- package/.agents/skills/saga-bearings/README.md +3 -0
- package/.agents/skills/saga-bearings/SKILL.md +26 -0
- package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
- package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
- package/.agents/skills/saga-bearings/assets/recap.md +56 -0
- package/.agents/skills/syn-recovery/README.md +3 -0
- package/.agents/skills/syn-recovery/SKILL.md +54 -0
- package/.agents/skills/tyr-check/README.md +3 -0
- package/.agents/skills/tyr-check/SKILL.md +218 -0
- package/.agents/skills/urdh-hold/README.md +3 -0
- package/.agents/skills/urdh-hold/SKILL.md +25 -0
- package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
- package/.agents/skills/urdh-hold/assets/hold.md +54 -0
- package/.agents/skills/vor-diagnostics/README.md +3 -0
- package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
- package/.agents/skills/ymir-host/README.md +3 -0
- package/.agents/skills/ymir-host/SKILL.md +35 -0
- package/.agents/skills/ymir-host/assets/desktop.md +36 -0
- package/.agents/skills/ymir-host/assets/install.md +69 -0
- package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
- package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
- package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
- package/.agents/skills/ymir-host/assets/update.md +64 -0
- package/.agents/tests/assets/board-render-harness.mjs +117 -0
- package/.agents/tests/cmux-test-safety.sh +51 -0
- package/.agents/tests/fixtures.sh +291 -0
- package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
- package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
- package/.agents/tests/fm-afk-launch.test.sh +961 -0
- package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
- package/.agents/tests/fm-afk-return.test.sh +283 -0
- package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
- package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
- package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
- package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
- package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
- package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
- package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
- package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
- package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
- package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
- package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
- package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
- package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
- package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
- package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
- package/.agents/tests/fm-backend-orca.test.sh +1357 -0
- package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
- package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
- package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
- package/.agents/tests/fm-backend.test.sh +1142 -0
- package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
- package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
- package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
- package/.agents/tests/fm-bearings-board.test.sh +414 -0
- package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
- package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
- package/.agents/tests/fm-bootstrap.test.sh +1178 -0
- package/.agents/tests/fm-branch-supervision.test.sh +552 -0
- package/.agents/tests/fm-brief.test.sh +785 -0
- package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
- package/.agents/tests/fm-busy-state.test.sh +465 -0
- package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
- package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
- package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
- package/.agents/tests/fm-check-unregister.test.sh +198 -0
- package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
- package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
- package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
- package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
- package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
- package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
- package/.agents/tests/fm-composer-ghost.test.sh +716 -0
- package/.agents/tests/fm-composer-lib.test.sh +666 -0
- package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
- package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
- package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
- package/.agents/tests/fm-control.test.sh +910 -0
- package/.agents/tests/fm-crew-state.test.sh +1610 -0
- package/.agents/tests/fm-cursor-harness.test.sh +404 -0
- package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
- package/.agents/tests/fm-cursor-primary.test.sh +708 -0
- package/.agents/tests/fm-daemon.test.sh +2740 -0
- package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
- package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
- package/.agents/tests/fm-extension-binding.test.sh +2187 -0
- package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
- package/.agents/tests/fm-fleet-sync.test.sh +721 -0
- package/.agents/tests/fm-gate-refuse.test.sh +356 -0
- package/.agents/tests/fm-gitignore-config.test.sh +89 -0
- package/.agents/tests/fm-gotmp.test.sh +230 -0
- package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
- package/.agents/tests/fm-grok-harness.test.sh +117 -0
- package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
- package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
- package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
- package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
- package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
- package/.agents/tests/fm-herdr-lab.test.sh +243 -0
- package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
- package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
- package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
- package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
- package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
- package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
- package/.agents/tests/fm-kimi-harness.test.sh +687 -0
- package/.agents/tests/fm-lint-workflows.test.sh +532 -0
- package/.agents/tests/fm-lint.test.sh +1023 -0
- package/.agents/tests/fm-muse-harness.test.sh +932 -0
- package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
- package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
- package/.agents/tests/fm-on.test.sh +507 -0
- package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
- package/.agents/tests/fm-operational-input.test.sh +160 -0
- package/.agents/tests/fm-peek-remote.test.sh +110 -0
- package/.agents/tests/fm-pending-reply.test.sh +1288 -0
- package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
- package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
- package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
- package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
- package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
- package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
- package/.agents/tests/fm-pr-merge.test.sh +2135 -0
- package/.agents/tests/fm-procevent-quota.test.sh +225 -0
- package/.agents/tests/fm-procevent-when.test.sh +406 -0
- package/.agents/tests/fm-procevent.test.sh +1745 -0
- package/.agents/tests/fm-project-origin.test.sh +110 -0
- package/.agents/tests/fm-public-followup.test.sh +2272 -0
- package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
- package/.agents/tests/fm-quota-choose.test.sh +626 -0
- package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
- package/.agents/tests/fm-remote-doctor.test.sh +625 -0
- package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
- package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
- package/.agents/tests/fm-remote-job.test.sh +768 -0
- package/.agents/tests/fm-remote-reply.test.sh +520 -0
- package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
- package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
- package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
- package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
- package/.agents/tests/fm-review-diff.test.sh +176 -0
- package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
- package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
- package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
- package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
- package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
- package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
- package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
- package/.agents/tests/fm-send-inbox.test.sh +352 -0
- package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
- package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
- package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
- package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
- package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
- package/.agents/tests/fm-send-settle.test.sh +147 -0
- package/.agents/tests/fm-send-strict.test.sh +241 -0
- package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
- package/.agents/tests/fm-session-start.test.sh +2515 -0
- package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
- package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
- package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
- package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
- package/.agents/tests/fm-spawn-batch.test.sh +148 -0
- package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
- package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
- package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
- package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
- package/.agents/tests/fm-startup-network.test.sh +712 -0
- package/.agents/tests/fm-stow-cascade.test.sh +370 -0
- package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
- package/.agents/tests/fm-supervision-events.test.sh +156 -0
- package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
- package/.agents/tests/fm-tangle-guard.test.sh +277 -0
- package/.agents/tests/fm-task-delivery.test.sh +420 -0
- package/.agents/tests/fm-task-inbox.test.sh +513 -0
- package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
- package/.agents/tests/fm-teardown.test.sh +2665 -0
- package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
- package/.agents/tests/fm-test-fixtures.test.sh +132 -0
- package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
- package/.agents/tests/fm-test-run.test.sh +1196 -0
- package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
- package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
- package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
- package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
- package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
- package/.agents/tests/fm-transition-lib.test.sh +54 -0
- package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
- package/.agents/tests/fm-update.test.sh +304 -0
- package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
- package/.agents/tests/fm-voice-relay.test.sh +4741 -0
- package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
- package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
- package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
- package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
- package/.agents/tests/fm-wake-queue.test.sh +1224 -0
- package/.agents/tests/fm-watch-arm.test.sh +821 -0
- package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
- package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
- package/.agents/tests/fm-watch-triage.test.sh +3889 -0
- package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
- package/.agents/tests/fm-x-mode.test.sh +3042 -0
- package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
- package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
- package/.agents/tests/herdr-test-safety.sh +42 -0
- package/.agents/tests/lib.sh +315 -0
- package/.agents/tests/remote-herdr-fixture.sh +125 -0
- package/.agents/tests/secondmate-helpers.sh +193 -0
- package/.agents/tests/smoke.test.sh +93 -0
- package/.agents/tests/wake-helpers.sh +338 -0
- package/.agents/tests/zellij-test-safety.sh +60 -0
- package/.agents/tools/README.md +10 -0
- package/.agents/tools/bin/tasks-cli +267 -0
- package/.agents/tools/tasks-cli.ts +216 -0
- package/AGENTS.md +498 -0
- package/CHANGELOG.md +1913 -0
- package/LICENSE +1 -1
- package/NOTICE +63 -0
- package/README.md +114 -60
- package/RULES/01-domains.md +51 -0
- package/RULES/02-agents.md +78 -0
- package/RULES/03-houses.md +61 -0
- package/RULES/04-hoard.md +97 -0
- package/RULES/05-platforms.md +67 -0
- package/RULES/06-append-only.md +108 -0
- package/RULES/07-config.md +101 -0
- package/RULES/08-delivery-gate.md +75 -0
- package/RULES/09-electron.md +58 -0
- package/RULES/README.md +22 -0
- package/bin/README.md +107 -0
- package/bin/a2a-mcp.sh +133 -0
- package/bin/a2a-serve.py +103 -0
- package/bin/a2a-serve.sh +8 -0
- package/bin/a2a-talk.sh +50 -0
- package/bin/a2abridge-ensure.sh +108 -0
- package/bin/agent-run.sh +43 -0
- package/bin/agents-config.sh +256 -0
- package/bin/apodex-smoke-test.sh +105 -0
- package/bin/bifrost-bridge.sh +131 -0
- package/bin/bifrost-ingress.sh +77 -0
- package/bin/bootstrap-macos.sh +112 -0
- package/bin/bootstrap-windows.ps1 +112 -0
- package/bin/branch-guard.sh +64 -0
- package/bin/brokk +73 -0
- package/bin/brokk-classify-lib.sh +1770 -0
- package/bin/brokk-lease-lib.sh +222 -0
- package/bin/brokk-lease.sh +193 -0
- package/bin/brokk-lint.sh +65 -0
- package/bin/brokk-send.sh +51 -0
- package/bin/brokk-update.sh +9 -0
- package/bin/brokk-wake-grant.sh +127 -0
- package/bin/brokk-wake-lib.sh +1849 -0
- package/bin/changelog-assemble.sh +154 -0
- package/bin/changelog-guard.sh +197 -0
- package/bin/crash-sense.sh +173 -0
- package/bin/design-check.sh +80 -0
- package/bin/design-icon.sh +153 -0
- package/bin/desktop-place.sh +224 -0
- package/bin/docs-guard.sh +39 -0
- package/bin/editor-place.sh +225 -0
- package/bin/eindri-acclaim.sh +47 -0
- package/bin/eindri-control.sh +28 -0
- package/bin/eindri-role.sh +83 -0
- package/bin/eindri-seat.sh +83 -0
- package/bin/eindri-seen.sh +54 -0
- package/bin/eindri-send.sh +15 -0
- package/bin/eindri-start.sh +98 -0
- package/bin/eindri-watch.sh +57 -0
- package/bin/einherjar-spawn.sh +576 -0
- package/bin/eir-doctor.sh +158 -0
- package/bin/erindi-brief.sh +271 -0
- package/bin/feature-inventory.sh +73 -0
- package/bin/fleet-apply.sh +81 -0
- package/bin/github-deploy.sh +94 -0
- package/bin/gjallarhorn-expose.sh +136 -0
- package/bin/gjallarhorn-notify.sh +71 -0
- package/bin/gjallarhorn-purge.sh +67 -0
- package/bin/gjallarhorn-tunnel.sh +85 -0
- package/bin/gleipnir-lock-lib.sh +284 -0
- package/bin/groa-update.sh +130 -0
- package/bin/hall-snapshot.sh +111 -0
- package/bin/hamr-harness.sh +246 -0
- package/bin/heimdall-ssh-keys.sh +374 -0
- package/bin/herdr-agents.py +44 -0
- package/bin/herdr-ensure.sh +104 -0
- package/bin/herdr-run.sh +452 -0
- package/bin/hermes-ensure.sh +88 -0
- package/bin/hlidskjalf-agents.sh +172 -0
- package/bin/hlidskjalf-usage.sh +196 -0
- package/bin/hoard-guard.sh +135 -0
- package/bin/hoard-lib.sh +105 -0
- package/bin/hodd.sh +102 -0
- package/bin/host-sense.sh +199 -0
- package/bin/huginn-research-worker.sh +121 -0
- package/bin/local-model-lock.sh +70 -0
- package/bin/mimir-bridge.py +259 -0
- package/bin/mimir-bridge.sh +100 -0
- package/bin/mimir-ingest.sh +131 -0
- package/bin/mimir-reflect.sh +27 -0
- package/bin/mimir.sh +113 -0
- package/bin/mjollnir-webhook.sh +72 -0
- package/bin/mjollnir.sh +104 -0
- package/bin/model-hardware.sh +96 -0
- package/bin/model-resolve.sh +160 -0
- package/bin/models-detect.sh +178 -0
- package/bin/nornir-cron-start.sh +162 -0
- package/bin/nornir-job-daily-briefing.sh +191 -0
- package/bin/nornir-job-git-sync.sh +152 -0
- package/bin/nornir-job-hall-snapshot.sh +28 -0
- package/bin/nornir-job-memory-housekeeping.sh +114 -0
- package/bin/nornir-job-nsr-compliance.sh +54 -0
- package/bin/nornir-job-observer.sh +166 -0
- package/bin/nornir-job-skillopt-sleep.sh +76 -0
- package/bin/npm-publish.sh +136 -0
- package/bin/omarchy-hook-install.sh +60 -0
- package/bin/omarchy-install.sh +161 -0
- package/bin/omarchy-plugins.sh +187 -0
- package/bin/omarchy-sense.sh +162 -0
- package/bin/perm-guard.sh +30 -0
- package/bin/pi-agent.sh +68 -0
- package/bin/pi-ensure.sh +77 -0
- package/bin/pi-local.sh +37 -0
- package/bin/pi-model-agnostic.sh +71 -0
- package/bin/pi-seat.sh +63 -0
- package/bin/prereq-ensure.sh +166 -0
- package/bin/private-guard.sh +58 -0
- package/bin/project-git.sh +69 -0
- package/bin/public-guard.sh +71 -0
- package/bin/ratatoskr.sh +49 -0
- package/bin/realm-lib.sh +32 -0
- package/bin/repo-scrub.sh +62 -0
- package/bin/rodd-operational-input.sh +250 -0
- package/bin/runes-append.sh +198 -0
- package/bin/saga-session-start.sh +172 -0
- package/bin/saga-sessionstart-run.sh +79 -0
- package/bin/saga-wake-drain.sh +41 -0
- package/bin/secret-guard.sh +63 -0
- package/bin/sessrumnir-ensure.sh +100 -0
- package/bin/sessrumnir-sync.sh +237 -0
- package/bin/sessrumnir.sh +104 -0
- package/bin/skillopt-setup.sh +76 -0
- package/bin/skuld-branch-outcome.sh +203 -0
- package/bin/skuld-branch-prompt.sh +101 -0
- package/bin/smidja-bootstrap.sh +94 -0
- package/bin/smidja-observe.sh +86 -0
- package/bin/syn-arm-pretool-check.sh +57 -0
- package/bin/syn-asset-pretool-check.sh +89 -0
- package/bin/syn-cd-pretool-check.sh +28 -0
- package/bin/syn-guard-pretool-check.sh +83 -0
- package/bin/syn-spawn-pretool-check.sh +58 -0
- package/bin/syn-turnend-guard.sh +36 -0
- package/bin/syn-watch-arm.sh +99 -0
- package/bin/tailscale-sync.sh +203 -0
- package/bin/telegram-bot.sh +82 -0
- package/bin/toolchain.sh +74 -0
- package/bin/ui-truth-check.sh +75 -0
- package/bin/utgard.sh +111 -0
- package/bin/valhalla.sh +115 -0
- package/bin/valknut-load.sh +298 -0
- package/bin/vor-crew-state.sh +124 -0
- package/bin/wedge-notify.sh +60 -0
- package/bin/workspace-provision.sh +80 -0
- package/bin/workspace-rag.sh +163 -0
- package/bin/wyrd-db.sh +47 -0
- package/bin/yggdrasil.sh +152 -0
- package/bin/ymir-edit.sh +145 -0
- package/bin/ymir-install.sh +806 -0
- package/bin/ymir-invite.sh +93 -0
- package/bin/ymir-isolation.sh +88 -0
- package/bin/ymir-migrate.sh +61 -0
- package/bin/ymir-plan.sh +356 -0
- package/bin/ymir-platform.sh +348 -0
- package/bin/ymir-say.sh +110 -0
- package/bin/ymir-setup-auth.sh +119 -0
- package/bin/ymir-validate.sh +193 -0
- package/bin/ymir.js +74 -16
- package/docs/Architecture.md +339 -0
- package/docs/design.md +351 -0
- package/docs/installations/README.md +63 -0
- package/docs/installations/ymir-home.md +93 -0
- package/docs/integration.md +105 -0
- package/docs/lore.md +1060 -0
- package/docs/research/agent-harnesses.md +290 -0
- package/docs/runbooks/README.md +19 -0
- package/docs/runbooks/a2a.md +91 -0
- package/docs/runbooks/agent-permissions.md +99 -0
- package/docs/runbooks/agents.md +57 -0
- package/docs/runbooks/models.md +69 -0
- package/docs/runbooks/realm-onboarding.md +60 -0
- package/docs/runbooks/secrets-and-hoard.md +60 -0
- package/docs/runbooks/updates-and-migrations.md +110 -0
- package/docs/runbooks/worktrees-isolation.md +72 -0
- package/docs/session-start.md +105 -0
- package/docs/skillopt-integration.md +321 -0
- package/docs/sync-system.md +66 -0
- package/docs/workspaces.md +104 -0
- package/docs/ymir-rut.md +260 -0
- package/package.json +54 -14
- package/packaging/build.sh +74 -0
- package/packaging/macos/Ymir Installer.command +43 -0
- package/packaging/windows/ymir-setup.nsi +74 -0
- package/scripts/README.md +22 -0
- package/scripts/electron.sh +269 -0
- package/scripts/lower.sh +34 -0
- package/scripts/raise.sh +42 -0
- package/scripts/start.sh +217 -0
- package/scripts/stop.sh +113 -0
- package/install.sh +0 -35
|
@@ -0,0 +1,2843 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Tests for the tracked Pi primary watcher extension and Pi secondmate wiring.
|
|
3
|
+
set -u
|
|
4
|
+
|
|
5
|
+
# shellcheck source=tests/lib.sh
|
|
6
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
|
7
|
+
|
|
8
|
+
TMP_ROOT=$(fm_test_tmproot fm-pi-watch-extension)
|
|
9
|
+
EXT="$ROOT/.pi/extensions/fm-primary-pi-watch.ts"
|
|
10
|
+
# Node 24 warns when these test-only dynamic imports load tracked ESM plugins
|
|
11
|
+
# from a clean checkout with no tracked .opencode/package.json. The warning is
|
|
12
|
+
# unrelated to plugin output, which the assertions intentionally require empty.
|
|
13
|
+
export NODE_NO_WARNINGS=1
|
|
14
|
+
|
|
15
|
+
# One owner for the readiness budget every unready-successor test below spends
|
|
16
|
+
# on purpose. Both plugins start a successor arm through a login shell and
|
|
17
|
+
# SIGTERM it when it stays silent past this budget, so the budget has to outlast
|
|
18
|
+
# a cold login-shell start. A successor killed before its first statement never
|
|
19
|
+
# appends its arm row and never installs the TERM trap these tests observe, so
|
|
20
|
+
# too small a budget reports a lost successor instead of the bounded recovery
|
|
21
|
+
# under test. A stock login shell already costs about 200ms on an idle
|
|
22
|
+
# workstation, and a loaded CI runner is slower, so keep an order of magnitude
|
|
23
|
+
# over that rather than a value that only holds locally. The wait loops in those
|
|
24
|
+
# tests are sized against this number.
|
|
25
|
+
ARM_READY_TIMEOUT_MS=2000
|
|
26
|
+
|
|
27
|
+
install_pi_watch_extension_fixture() {
|
|
28
|
+
local repo=$1
|
|
29
|
+
mkdir -p \
|
|
30
|
+
"$repo/.pi/extensions/lib" \
|
|
31
|
+
"$repo/node_modules/@earendil-works/pi-coding-agent" \
|
|
32
|
+
"$repo/node_modules/@earendil-works/pi-tui" \
|
|
33
|
+
"$repo/node_modules/typebox"
|
|
34
|
+
cp "$EXT" "$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
35
|
+
cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$repo/.pi/extensions/lib/fm-branch-dispatch.ts"
|
|
36
|
+
cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$repo/.pi/extensions/lib/fm-calm-visibility.ts"
|
|
37
|
+
cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts"
|
|
38
|
+
mkdir -p "$repo/bin"
|
|
39
|
+
cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh"
|
|
40
|
+
chmod +x "$repo/bin/fm-operational-input.sh"
|
|
41
|
+
cat > "$repo/node_modules/@earendil-works/pi-coding-agent/package.json" <<'JSON'
|
|
42
|
+
{"name":"@earendil-works/pi-coding-agent","type":"module","exports":"./index.js"}
|
|
43
|
+
JSON
|
|
44
|
+
cat > "$repo/node_modules/@earendil-works/pi-coding-agent/index.js" <<'JS'
|
|
45
|
+
export function getMarkdownTheme() { return {}; }
|
|
46
|
+
export class UserMessageComponent {
|
|
47
|
+
render() { return []; }
|
|
48
|
+
invalidate() {}
|
|
49
|
+
}
|
|
50
|
+
JS
|
|
51
|
+
cat > "$repo/node_modules/@earendil-works/pi-tui/package.json" <<'JSON'
|
|
52
|
+
{"name":"@earendil-works/pi-tui","type":"module","exports":"./index.js"}
|
|
53
|
+
JSON
|
|
54
|
+
cat > "$repo/node_modules/@earendil-works/pi-tui/index.js" <<'JS'
|
|
55
|
+
export class Box {
|
|
56
|
+
addChild() {}
|
|
57
|
+
clear() {}
|
|
58
|
+
setBgFn() {}
|
|
59
|
+
}
|
|
60
|
+
export class Container {}
|
|
61
|
+
export class Text {}
|
|
62
|
+
JS
|
|
63
|
+
cat > "$repo/node_modules/typebox/package.json" <<'JSON'
|
|
64
|
+
{"name":"typebox","type":"module","exports":"./index.js"}
|
|
65
|
+
JSON
|
|
66
|
+
cat > "$repo/node_modules/typebox/index.js" <<'JS'
|
|
67
|
+
export const Type = {
|
|
68
|
+
Object(properties) {
|
|
69
|
+
return { type: "object", properties, additionalProperties: false };
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
JS
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test_pi_extension_reports_external_healthy_watcher() {
|
|
76
|
+
local repo home plugin out status
|
|
77
|
+
repo="$TMP_ROOT/pi-external-healthy-root"
|
|
78
|
+
home="$TMP_ROOT/pi-external-healthy-home"
|
|
79
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
80
|
+
install_pi_watch_extension_fixture "$repo"
|
|
81
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
82
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
83
|
+
#!/usr/bin/env bash
|
|
84
|
+
printf 'watcher: healthy pid=1 (beacon 0s)\n'
|
|
85
|
+
SH
|
|
86
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
87
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
|
|
88
|
+
import { writeFileSync } from "node:fs";
|
|
89
|
+
import { pathToFileURL } from "node:url";
|
|
90
|
+
|
|
91
|
+
let handler = null;
|
|
92
|
+
let notification = "";
|
|
93
|
+
let prompt = "";
|
|
94
|
+
const pi = {
|
|
95
|
+
on() {},
|
|
96
|
+
registerCommand(name, options) {
|
|
97
|
+
if (name === "fm-watch-arm-pi") handler = options.handler;
|
|
98
|
+
},
|
|
99
|
+
registerTool() {},
|
|
100
|
+
sendUserMessage: async (message) => {
|
|
101
|
+
prompt = message;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
105
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
106
|
+
mod.default(pi);
|
|
107
|
+
if (!handler) {
|
|
108
|
+
console.error("Pi watch command was not registered");
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
const result = await handler("", {
|
|
112
|
+
ui: {
|
|
113
|
+
notify(message) {
|
|
114
|
+
notification = message;
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
if (result !== undefined) {
|
|
119
|
+
console.error(`Pi command returned a value: ${String(result)}`);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
if (!notification.includes("started Pi extension arm child")) {
|
|
123
|
+
console.error(notification);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
for (let i = 0; i < 250 && !prompt; i += 1) {
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
128
|
+
}
|
|
129
|
+
if (!prompt.startsWith("\u2063FIRSTMATE_OP: v1 watcher: ")) {
|
|
130
|
+
console.error(`untyped operational follow-up: ${prompt}`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
if (!prompt.includes("FIRSTMATE WATCHER WAKE")) {
|
|
134
|
+
console.error(`missing follow-up prompt: ${prompt}`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
if (!prompt.includes("external healthy watcher")) {
|
|
138
|
+
console.error(prompt);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
if (!prompt.includes("watcher: healthy pid=1")) {
|
|
142
|
+
console.error(prompt);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
EOF
|
|
146
|
+
)
|
|
147
|
+
status=$?
|
|
148
|
+
expect_code 0 "$status" "Pi extension must surface an external healthy watcher as an owned-wake failure"
|
|
149
|
+
[ -z "$out" ] || fail "Pi external-healthy test printed output: $out"
|
|
150
|
+
pass "Pi extension reports external healthy watcher output"
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
test_pi_tool_returns_agent_tool_result() {
|
|
154
|
+
local repo home plugin out status
|
|
155
|
+
repo="$TMP_ROOT/pi-tool-result-root"
|
|
156
|
+
home="$TMP_ROOT/pi-tool-result-home"
|
|
157
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
158
|
+
install_pi_watch_extension_fixture "$repo"
|
|
159
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
160
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
161
|
+
#!/usr/bin/env bash
|
|
162
|
+
exit 0
|
|
163
|
+
SH
|
|
164
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
165
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" node --input-type=module 2>&1 <<'EOF'
|
|
166
|
+
import { writeFileSync } from "node:fs";
|
|
167
|
+
import { pathToFileURL } from "node:url";
|
|
168
|
+
|
|
169
|
+
let tool = null;
|
|
170
|
+
const pi = {
|
|
171
|
+
on() {},
|
|
172
|
+
registerCommand() {},
|
|
173
|
+
registerTool(candidate) {
|
|
174
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
175
|
+
},
|
|
176
|
+
sendUserMessage: async () => {},
|
|
177
|
+
};
|
|
178
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
179
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
180
|
+
mod.default(pi);
|
|
181
|
+
if (!tool) throw new Error("Pi watch tool was not registered");
|
|
182
|
+
if (tool.label !== "Arm firstmate watcher") throw new Error(`unexpected label: ${tool.label}`);
|
|
183
|
+
if (tool.parameters?.type !== "object") throw new Error("tool parameters are not a TypeBox object schema");
|
|
184
|
+
const metadata = [tool.description, tool.promptSnippet, ...(tool.promptGuidelines ?? [])].join("\n");
|
|
185
|
+
if (metadata.includes("Always use this tool")) throw new Error(`broad tool-selection metadata remained visible: ${metadata}`);
|
|
186
|
+
if (!tool.description.includes("first required Pi watcher cycle")) throw new Error(`tool description omitted the first-cycle condition: ${tool.description}`);
|
|
187
|
+
if (!tool.promptSnippet.includes("ordinary re-arming is automatic")) throw new Error(`tool snippet omitted automatic continuation: ${tool.promptSnippet}`);
|
|
188
|
+
if (!tool.promptGuidelines.some((guideline) => guideline.includes("ordinary signal, stale, check, or heartbeat handling"))) {
|
|
189
|
+
throw new Error(`tool guidelines omitted ordinary-notification prevention: ${tool.promptGuidelines}`);
|
|
190
|
+
}
|
|
191
|
+
const result = await tool.execute("tool-call-1", {}, undefined, undefined, {});
|
|
192
|
+
if (!Array.isArray(result.content) || result.content[0]?.type !== "text") {
|
|
193
|
+
throw new Error(`invalid tool content: ${JSON.stringify(result)}`);
|
|
194
|
+
}
|
|
195
|
+
if (!result.content[0].text.includes("started Pi extension arm child")) {
|
|
196
|
+
throw new Error(`unexpected tool text: ${result.content[0].text}`);
|
|
197
|
+
}
|
|
198
|
+
if (!result.content[0].text.includes("future ordinary re-arms are automatic")) {
|
|
199
|
+
throw new Error(`initial tool result omitted automatic continuation guidance: ${result.content[0].text}`);
|
|
200
|
+
}
|
|
201
|
+
if (!result.content[0].text.includes("only after a later notification says the cycle is missing, failed, or unhealthy")) {
|
|
202
|
+
throw new Error(`initial tool result omitted the repair-only condition: ${result.content[0].text}`);
|
|
203
|
+
}
|
|
204
|
+
if (result.details?.ok !== true || result.details?.message !== result.content[0].text) {
|
|
205
|
+
throw new Error(`invalid tool details: ${JSON.stringify(result.details)}`);
|
|
206
|
+
}
|
|
207
|
+
EOF
|
|
208
|
+
)
|
|
209
|
+
status=$?
|
|
210
|
+
expect_code 0 "$status" "Pi custom tool must expose first-cycle or repair-only metadata and return Pi's AgentToolResult shape"
|
|
211
|
+
[ -z "$out" ] || fail "Pi tool-result test printed output: $out"
|
|
212
|
+
pass "Pi custom tool exposes repair-only metadata and returns automatic-continuation guidance"
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
test_pi_redundant_tool_call_is_owned_noop() {
|
|
216
|
+
local repo home plugin log stop out status
|
|
217
|
+
repo="$TMP_ROOT/pi-redundant-tool-root"
|
|
218
|
+
home="$TMP_ROOT/pi-redundant-tool-home"
|
|
219
|
+
log="$TMP_ROOT/pi-redundant-tool.log"
|
|
220
|
+
stop="$TMP_ROOT/pi-redundant-tool.stop"
|
|
221
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
222
|
+
install_pi_watch_extension_fixture "$repo"
|
|
223
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
224
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
225
|
+
#!/usr/bin/env bash
|
|
226
|
+
printf 'arm\n' >> "${FM_ARM_LOG:?}"
|
|
227
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
228
|
+
trap 'exit 0' TERM INT
|
|
229
|
+
while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
|
|
230
|
+
SH
|
|
231
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
232
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF'
|
|
233
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
234
|
+
import { pathToFileURL } from "node:url";
|
|
235
|
+
|
|
236
|
+
let tool = null;
|
|
237
|
+
const pi = {
|
|
238
|
+
on() {},
|
|
239
|
+
registerCommand() {},
|
|
240
|
+
registerTool(candidate) {
|
|
241
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
242
|
+
},
|
|
243
|
+
sendUserMessage: async () => {},
|
|
244
|
+
};
|
|
245
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
246
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
247
|
+
mod.default(pi);
|
|
248
|
+
const initial = await tool.execute("tool-call-first", {}, undefined, undefined, {});
|
|
249
|
+
if (!initial.content[0]?.text.includes("started Pi extension arm child")) {
|
|
250
|
+
throw new Error(`initial call did not start the arm child: ${initial.content[0]?.text}`);
|
|
251
|
+
}
|
|
252
|
+
const redundant = await tool.execute("tool-call-redundant", {}, undefined, undefined, {});
|
|
253
|
+
if (!redundant.content[0]?.text.includes("Pi extension already owns an arm child; no manual re-arm needed")) {
|
|
254
|
+
throw new Error(`redundant call omitted ownership-based no-op guidance: ${redundant.content[0]?.text}`);
|
|
255
|
+
}
|
|
256
|
+
if (/^watcher: healthy\b/.test(redundant.content[0]?.text)) {
|
|
257
|
+
throw new Error(`redundant call overclaimed independent health: ${redundant.content[0]?.text}`);
|
|
258
|
+
}
|
|
259
|
+
if (!redundant.content[0]?.text.includes("only after a later notification says the cycle is missing, failed, or unhealthy")) {
|
|
260
|
+
throw new Error(`redundant call omitted the repair-only condition: ${redundant.content[0]?.text}`);
|
|
261
|
+
}
|
|
262
|
+
for (let i = 0; i < 100 && !existsSync(process.env.FM_ARM_LOG); i += 1) {
|
|
263
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
264
|
+
}
|
|
265
|
+
if (!existsSync(process.env.FM_ARM_LOG)) throw new Error("initial arm child did not start");
|
|
266
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
267
|
+
const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
268
|
+
if (rows.length !== 1) throw new Error(`redundant call spawned ${rows.length} arm children`);
|
|
269
|
+
writeFileSync(process.env.FM_STOP_FILE, "stop\n");
|
|
270
|
+
EOF
|
|
271
|
+
)
|
|
272
|
+
status=$?
|
|
273
|
+
expect_code 0 "$status" "Pi redundant tool call must remain an ownership-based no-op with repair-only guidance"
|
|
274
|
+
[ -z "$out" ] || fail "Pi redundant-call test printed output: $out"
|
|
275
|
+
pass "Pi redundant tool call returns ownership guidance and spawns no second child"
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
test_pi_scheduled_retry_call_is_owned_noop() {
|
|
279
|
+
local repo home plugin log out status
|
|
280
|
+
repo="$TMP_ROOT/pi-scheduled-retry-root"
|
|
281
|
+
home="$TMP_ROOT/pi-scheduled-retry-home"
|
|
282
|
+
log="$TMP_ROOT/pi-scheduled-retry.log"
|
|
283
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
284
|
+
install_pi_watch_extension_fixture "$repo"
|
|
285
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
286
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
287
|
+
#!/usr/bin/env bash
|
|
288
|
+
printf 'arm\n' >> "${FM_ARM_LOG:?}"
|
|
289
|
+
exit 0
|
|
290
|
+
SH
|
|
291
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
292
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_WATCH_REARM_RETRY_BASE_MS=10000 FM_WATCH_REARM_RETRY_MAX_MS=10000 node --input-type=module 2>&1 <<'EOF'
|
|
293
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
294
|
+
import { pathToFileURL } from "node:url";
|
|
295
|
+
|
|
296
|
+
let tool = null;
|
|
297
|
+
const pi = {
|
|
298
|
+
on() {},
|
|
299
|
+
registerCommand() {},
|
|
300
|
+
registerTool(candidate) {
|
|
301
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
302
|
+
},
|
|
303
|
+
sendUserMessage: async () => {},
|
|
304
|
+
};
|
|
305
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
306
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
307
|
+
mod.default(pi);
|
|
308
|
+
await tool.execute("tool-call-first", {}, undefined, undefined, {});
|
|
309
|
+
let redundant = null;
|
|
310
|
+
for (let i = 0; i < 100; i += 1) {
|
|
311
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
312
|
+
redundant = await tool.execute("tool-call-during-retry", {}, undefined, undefined, {});
|
|
313
|
+
if (redundant.content[0]?.text.includes("scheduled continuity retry")) break;
|
|
314
|
+
}
|
|
315
|
+
if (!redundant?.content[0]?.text.includes("Pi extension already owns a scheduled continuity retry; no manual re-arm needed")) {
|
|
316
|
+
throw new Error(`scheduled retry did not return ownership-based no-op guidance: ${redundant?.content[0]?.text}`);
|
|
317
|
+
}
|
|
318
|
+
if (/^watcher: healthy\b/.test(redundant.content[0]?.text)) {
|
|
319
|
+
throw new Error(`scheduled retry call overclaimed independent health: ${redundant.content[0]?.text}`);
|
|
320
|
+
}
|
|
321
|
+
if (!redundant.content[0]?.text.includes("only after a later notification says the cycle is missing, failed, or unhealthy")) {
|
|
322
|
+
throw new Error(`scheduled retry call omitted the repair-only condition: ${redundant.content[0]?.text}`);
|
|
323
|
+
}
|
|
324
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
325
|
+
const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
326
|
+
if (rows.length !== 1) throw new Error(`scheduled retry call spawned ${rows.length} arm children`);
|
|
327
|
+
EOF
|
|
328
|
+
)
|
|
329
|
+
status=$?
|
|
330
|
+
expect_code 0 "$status" "Pi scheduled-retry call must not duplicate the extension-owned retry"
|
|
331
|
+
[ -z "$out" ] || fail "Pi scheduled-retry test printed output: $out"
|
|
332
|
+
pass "Pi scheduled retry remains extension-owned after another tool call"
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
test_pi_actionable_close_starts_single_successor_before_delivery() {
|
|
336
|
+
local repo home plugin log stop out status
|
|
337
|
+
repo="$TMP_ROOT/pi-continuous-rearm-root"
|
|
338
|
+
home="$TMP_ROOT/pi-continuous-rearm-home"
|
|
339
|
+
log="$TMP_ROOT/pi-continuous-rearm.log"
|
|
340
|
+
stop="$TMP_ROOT/pi-continuous-rearm.stop"
|
|
341
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
342
|
+
install_pi_watch_extension_fixture "$repo"
|
|
343
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
344
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
345
|
+
#!/usr/bin/env bash
|
|
346
|
+
if [ "${1:-}" = --handling-delivered ]; then
|
|
347
|
+
printf 'confirmed generation=%s watcher=%s\n' "$2" "$4" >> "${FM_ARM_LOG:?}"
|
|
348
|
+
exit 0
|
|
349
|
+
fi
|
|
350
|
+
printf 'arm=%s predecessor=%s\n' "$$" "${FM_WATCH_PREDECESSOR_ARM_PID:-none}" >> "${FM_ARM_LOG:?}"
|
|
351
|
+
count=$(grep -c '^arm=' "$FM_ARM_LOG")
|
|
352
|
+
if [ "$count" -eq 1 ]; then
|
|
353
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
354
|
+
printf 'signal: synthetic actionable close\n'
|
|
355
|
+
exit 0
|
|
356
|
+
fi
|
|
357
|
+
printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$"
|
|
358
|
+
trap 'exit 0' TERM INT
|
|
359
|
+
while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
|
|
360
|
+
SH
|
|
361
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
362
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF'
|
|
363
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
364
|
+
import { pathToFileURL } from "node:url";
|
|
365
|
+
|
|
366
|
+
let tool = null;
|
|
367
|
+
let deliveryStarted = false;
|
|
368
|
+
let rowsAtDelivery = 0;
|
|
369
|
+
let releaseDelivery = () => {};
|
|
370
|
+
const deliveryBlocked = new Promise((resolve) => {
|
|
371
|
+
releaseDelivery = resolve;
|
|
372
|
+
});
|
|
373
|
+
const pi = {
|
|
374
|
+
on() {},
|
|
375
|
+
registerCommand() {},
|
|
376
|
+
registerTool(candidate) {
|
|
377
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
378
|
+
},
|
|
379
|
+
sendUserMessage: async () => {
|
|
380
|
+
rowsAtDelivery = existsSync(process.env.FM_ARM_LOG)
|
|
381
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n").filter((row) => row.startsWith("arm=")).length
|
|
382
|
+
: 0;
|
|
383
|
+
deliveryStarted = true;
|
|
384
|
+
await deliveryBlocked;
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
388
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
389
|
+
mod.default(pi);
|
|
390
|
+
await tool.execute("tool-call-continuity", {}, undefined, undefined, {});
|
|
391
|
+
for (let i = 0; i < 250; i += 1) {
|
|
392
|
+
const rows = existsSync(process.env.FM_ARM_LOG)
|
|
393
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
|
|
394
|
+
: [];
|
|
395
|
+
if (rows.length >= 2 && deliveryStarted) break;
|
|
396
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
397
|
+
}
|
|
398
|
+
const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
399
|
+
const armRows = rows.filter((row) => row.startsWith("arm="));
|
|
400
|
+
if (armRows.length !== 2) throw new Error(`expected one successor arm, got ${armRows.length}: ${rows.join(" | ")}`);
|
|
401
|
+
if (!deliveryStarted) throw new Error("wake delivery did not begin");
|
|
402
|
+
if (rowsAtDelivery !== 2) throw new Error(`wake delivery began before successor establishment (${rowsAtDelivery} arm rows)`);
|
|
403
|
+
if (!/predecessor=[0-9]+/.test(armRows[1])) throw new Error(`successor did not receive predecessor identity: ${armRows[1]}`);
|
|
404
|
+
if (!rows.some((row) => row.startsWith("confirmed generation=fixture-generation"))) {
|
|
405
|
+
throw new Error(`handling delivery was not confirmed before the follow-up: ${rows.join(" | ")}`);
|
|
406
|
+
}
|
|
407
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
408
|
+
const stableRows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
409
|
+
if (stableRows.filter((row) => row.startsWith("arm=")).length !== 2) {
|
|
410
|
+
throw new Error(`blocked follow-up started extra arm work: ${stableRows.join(" | ")}`);
|
|
411
|
+
}
|
|
412
|
+
if (stableRows.filter((row) => row.startsWith("confirmed ")).length !== 1) {
|
|
413
|
+
throw new Error(`successful prompt delivery was not confirmed exactly once: ${stableRows.join(" | ")}`);
|
|
414
|
+
}
|
|
415
|
+
releaseDelivery();
|
|
416
|
+
writeFileSync(process.env.FM_STOP_FILE, "stop\n");
|
|
417
|
+
process.exit(0);
|
|
418
|
+
EOF
|
|
419
|
+
)
|
|
420
|
+
status=$?
|
|
421
|
+
expect_code 0 "$status" "Pi actionable close must start one successor before wake delivery settles"
|
|
422
|
+
[ -z "$out" ] || fail "Pi continuous-rearm test printed output: $out"
|
|
423
|
+
pass "Pi actionable close starts one successor before wake delivery settles"
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
test_pi_branch_offer_owns_actionable_wake() {
|
|
427
|
+
local repo home plugin log stop out status
|
|
428
|
+
repo="$TMP_ROOT/pi-branch-offer-root"
|
|
429
|
+
home="$TMP_ROOT/pi-branch-offer-home"
|
|
430
|
+
log="$TMP_ROOT/pi-branch-offer.log"
|
|
431
|
+
stop="$TMP_ROOT/pi-branch-offer.stop"
|
|
432
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
433
|
+
install_pi_watch_extension_fixture "$repo"
|
|
434
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
435
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
436
|
+
#!/usr/bin/env bash
|
|
437
|
+
if [ "${1:-}" = --handling-delivered ]; then
|
|
438
|
+
printf 'confirmed generation=%s watcher=%s\n' "$2" "$4" >> "${FM_ARM_LOG:?}"
|
|
439
|
+
exit 0
|
|
440
|
+
fi
|
|
441
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
442
|
+
count=$(grep -c '^arm=' "$FM_ARM_LOG")
|
|
443
|
+
if [ "$count" -eq 1 ]; then
|
|
444
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
445
|
+
printf 'signal: branch-offer synthetic wake\n'
|
|
446
|
+
exit 0
|
|
447
|
+
fi
|
|
448
|
+
printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$"
|
|
449
|
+
trap 'exit 0' TERM INT
|
|
450
|
+
while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
|
|
451
|
+
SH
|
|
452
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
453
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF'
|
|
454
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
455
|
+
import { pathToFileURL } from "node:url";
|
|
456
|
+
|
|
457
|
+
// Two independent runs against the SAME dispatcher build: with an accepting
|
|
458
|
+
// branch listener the wake must be owned by the branch (no main follow-up);
|
|
459
|
+
// with a bus but no acceptor the dispatcher must fall back to main. The
|
|
460
|
+
// divergence between the two runs is asserted, so the case cannot go vacuous.
|
|
461
|
+
async function runScenario(withAcceptor) {
|
|
462
|
+
writeFileSync(process.env.FM_ARM_LOG, "");
|
|
463
|
+
const offers = [];
|
|
464
|
+
let mainPrompt = "";
|
|
465
|
+
let tool = null;
|
|
466
|
+
const handlers = new Map();
|
|
467
|
+
const bus = {
|
|
468
|
+
on(channel, handler) {
|
|
469
|
+
handlers.set(channel, [...(handlers.get(channel) ?? []), handler]);
|
|
470
|
+
return () => {};
|
|
471
|
+
},
|
|
472
|
+
emit(channel, data) {
|
|
473
|
+
for (const handler of handlers.get(channel) ?? []) handler(data);
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
if (withAcceptor) {
|
|
477
|
+
bus.on("fm-branch-supervision:dispatch", (offer) => {
|
|
478
|
+
offers.push({ message: offer.message, projects: offer.projects });
|
|
479
|
+
offer.accept();
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
const pi = {
|
|
483
|
+
on() {},
|
|
484
|
+
events: bus,
|
|
485
|
+
registerCommand() {},
|
|
486
|
+
registerTool(candidate) {
|
|
487
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
488
|
+
},
|
|
489
|
+
sendUserMessage: async (message) => {
|
|
490
|
+
mainPrompt = message;
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
const mod = await import(`${pathToFileURL(process.env.PLUGIN).href}?scenario=${withAcceptor}`);
|
|
494
|
+
mod.default(pi);
|
|
495
|
+
await tool.execute("tool-call-branch-offer", {}, undefined, undefined, {});
|
|
496
|
+
for (let i = 0; i < 250; i += 1) {
|
|
497
|
+
const settled = withAcceptor ? offers.length > 0 : mainPrompt !== "";
|
|
498
|
+
if (settled) break;
|
|
499
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
500
|
+
}
|
|
501
|
+
const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
502
|
+
return { offers, mainPrompt, rows };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
506
|
+
writeFileSync(`${process.env.FM_HOME}/state/branch-offer.meta`, "project=/projects/approved\nwindow=fm-branch-offer\n");
|
|
507
|
+
writeFileSync(`${process.env.FM_HOME}/state/.wake-queue`, "1\t1\tsignal\tbranch-offer.status\tsignal: branch-offer synthetic wake\n");
|
|
508
|
+
const accepted = await runScenario(true);
|
|
509
|
+
if (accepted.offers.length !== 1) throw new Error(`expected one branch offer, got ${accepted.offers.length}`);
|
|
510
|
+
if (!accepted.offers[0].message.includes("signal: branch-offer synthetic wake")) {
|
|
511
|
+
throw new Error(`offer missed the wake reason: ${accepted.offers[0].message}`);
|
|
512
|
+
}
|
|
513
|
+
if (JSON.stringify(accepted.offers[0].projects) !== JSON.stringify(["/projects/approved"])) {
|
|
514
|
+
throw new Error(`offer did not carry the queued task project: ${JSON.stringify(accepted.offers[0].projects)}`);
|
|
515
|
+
}
|
|
516
|
+
if (accepted.mainPrompt !== "") throw new Error(`accepted offer still reached main: ${accepted.mainPrompt}`);
|
|
517
|
+
if (!accepted.rows.some((row) => row.startsWith("confirmed generation=fixture-generation"))) {
|
|
518
|
+
throw new Error(`handling delivery was not confirmed before the branch handoff: ${accepted.rows.join(" | ")}`);
|
|
519
|
+
}
|
|
520
|
+
const declined = await runScenario(false);
|
|
521
|
+
if (declined.offers.length !== 0) throw new Error("no-acceptor scenario recorded an offer");
|
|
522
|
+
if (!declined.mainPrompt.includes("FIRSTMATE WATCHER WAKE")) {
|
|
523
|
+
throw new Error(`unaccepted offer did not fall back to main: ${declined.mainPrompt}`);
|
|
524
|
+
}
|
|
525
|
+
if (!declined.mainPrompt.includes("signal: branch-offer synthetic wake")) {
|
|
526
|
+
throw new Error(`fallback wake lost the reason line: ${declined.mainPrompt}`);
|
|
527
|
+
}
|
|
528
|
+
writeFileSync(process.env.FM_STOP_FILE, "stop\n");
|
|
529
|
+
process.exit(0);
|
|
530
|
+
EOF
|
|
531
|
+
)
|
|
532
|
+
status=$?
|
|
533
|
+
expect_code 0 "$status" "Pi dispatcher must hand an accepted wake to the branch and fall back to main otherwise"
|
|
534
|
+
[ -z "$out" ] || fail "Pi branch-offer test printed output: $out"
|
|
535
|
+
pass "Pi dispatcher branch offer owns accepted wakes and falls back to main"
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
test_pi_branch_offer_flags_heartbeat() {
|
|
539
|
+
local repo home plugin log stop out status
|
|
540
|
+
repo="$TMP_ROOT/pi-branch-heartbeat-root"
|
|
541
|
+
home="$TMP_ROOT/pi-branch-heartbeat-home"
|
|
542
|
+
log="$TMP_ROOT/pi-branch-heartbeat.log"
|
|
543
|
+
stop="$TMP_ROOT/pi-branch-heartbeat.stop"
|
|
544
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
545
|
+
install_pi_watch_extension_fixture "$repo"
|
|
546
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
547
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
548
|
+
#!/usr/bin/env bash
|
|
549
|
+
if [ "${1:-}" = --handling-delivered ]; then
|
|
550
|
+
printf 'confirmed generation=%s watcher=%s\n' "$2" "$4" >> "${FM_ARM_LOG:?}"
|
|
551
|
+
exit 0
|
|
552
|
+
fi
|
|
553
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
554
|
+
count=$(grep -c '^arm=' "$FM_ARM_LOG")
|
|
555
|
+
if [ "$count" -eq 1 ]; then
|
|
556
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
557
|
+
printf 'heartbeat\n'
|
|
558
|
+
exit 0
|
|
559
|
+
fi
|
|
560
|
+
printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$"
|
|
561
|
+
trap 'exit 0' TERM INT
|
|
562
|
+
while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
|
|
563
|
+
SH
|
|
564
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
565
|
+
# A bare heartbeat is the real wake emitted after the cheap bash scan flags
|
|
566
|
+
# a fleet pass as possibly captain-relevant. It has no task-scoped queue row,
|
|
567
|
+
# so branch eligibility must remain independent of project resolution.
|
|
568
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF'
|
|
569
|
+
import { writeFileSync } from "node:fs";
|
|
570
|
+
import { pathToFileURL } from "node:url";
|
|
571
|
+
|
|
572
|
+
const offers = [];
|
|
573
|
+
const handlers = new Map();
|
|
574
|
+
const bus = {
|
|
575
|
+
on(channel, handler) {
|
|
576
|
+
handlers.set(channel, [...(handlers.get(channel) ?? []), handler]);
|
|
577
|
+
return () => {};
|
|
578
|
+
},
|
|
579
|
+
emit(channel, data) {
|
|
580
|
+
for (const handler of handlers.get(channel) ?? []) handler(data);
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
bus.on("fm-branch-supervision:dispatch", (offer) => {
|
|
584
|
+
offers.push({ message: offer.message, projects: offer.projects, heartbeat: offer.heartbeat, eligible: offer.eligible });
|
|
585
|
+
offer.accept();
|
|
586
|
+
});
|
|
587
|
+
let tool = null;
|
|
588
|
+
const pi = {
|
|
589
|
+
on() {},
|
|
590
|
+
events: bus,
|
|
591
|
+
registerCommand() {},
|
|
592
|
+
registerTool(candidate) {
|
|
593
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
594
|
+
},
|
|
595
|
+
sendUserMessage: async () => {},
|
|
596
|
+
};
|
|
597
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
598
|
+
writeFileSync(`${process.env.FM_HOME}/state/.wake-queue`, "1\t1\theartbeat\theartbeat\theartbeat\n");
|
|
599
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
600
|
+
mod.default(pi);
|
|
601
|
+
await tool.execute("tool-call-branch-heartbeat", {}, undefined, undefined, {});
|
|
602
|
+
for (let i = 0; i < 250 && offers.length === 0; i += 1) {
|
|
603
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
604
|
+
}
|
|
605
|
+
if (offers.length !== 1) throw new Error(`expected one branch offer, got ${offers.length}`);
|
|
606
|
+
if (offers[0].message !== "heartbeat") {
|
|
607
|
+
throw new Error(`offer changed the bare heartbeat wake reason: ${offers[0].message}`);
|
|
608
|
+
}
|
|
609
|
+
if (offers[0].heartbeat !== true || offers[0].eligible !== true) throw new Error(`heartbeat offer was not eligible: ${JSON.stringify(offers[0])}`);
|
|
610
|
+
if (JSON.stringify(offers[0].projects) !== JSON.stringify([])) {
|
|
611
|
+
throw new Error(`heartbeat offer unexpectedly carried scoped projects: ${JSON.stringify(offers[0].projects)}`);
|
|
612
|
+
}
|
|
613
|
+
writeFileSync(process.env.FM_STOP_FILE, "stop\n");
|
|
614
|
+
process.exit(0);
|
|
615
|
+
EOF
|
|
616
|
+
)
|
|
617
|
+
status=$?
|
|
618
|
+
expect_code 0 "$status" "Pi dispatcher must flag a heartbeat offer independent of task scoping"
|
|
619
|
+
[ -z "$out" ] || fail "Pi branch-heartbeat test printed output: $out"
|
|
620
|
+
pass "Pi dispatcher flags a fleet-wide heartbeat offer as branch-eligible"
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_check() {
|
|
624
|
+
local repo home plugin log stop out status
|
|
625
|
+
repo="$TMP_ROOT/pi-heartbeat-mixed-queue-root"
|
|
626
|
+
home="$TMP_ROOT/pi-heartbeat-mixed-queue-home"
|
|
627
|
+
log="$TMP_ROOT/pi-heartbeat-mixed-queue.log"
|
|
628
|
+
stop="$TMP_ROOT/pi-heartbeat-mixed-queue.stop"
|
|
629
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
630
|
+
install_pi_watch_extension_fixture "$repo"
|
|
631
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
632
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
633
|
+
#!/usr/bin/env bash
|
|
634
|
+
if [ "${1:-}" = --handling-delivered ]; then exit 0; fi
|
|
635
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
636
|
+
count=$(grep -c '^arm=' "$FM_ARM_LOG")
|
|
637
|
+
if [ "$count" -eq 1 ]; then
|
|
638
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
639
|
+
printf 'heartbeat\n'
|
|
640
|
+
exit 0
|
|
641
|
+
fi
|
|
642
|
+
printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$"
|
|
643
|
+
trap 'exit 0' TERM INT
|
|
644
|
+
while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
|
|
645
|
+
SH
|
|
646
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
647
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF'
|
|
648
|
+
import { writeFileSync } from "node:fs";
|
|
649
|
+
import { pathToFileURL } from "node:url";
|
|
650
|
+
|
|
651
|
+
const offers = [];
|
|
652
|
+
let prompt = "";
|
|
653
|
+
let tool = null;
|
|
654
|
+
const handlers = new Map();
|
|
655
|
+
const bus = {
|
|
656
|
+
on(channel, handler) {
|
|
657
|
+
handlers.set(channel, [...(handlers.get(channel) ?? []), handler]);
|
|
658
|
+
return () => {};
|
|
659
|
+
},
|
|
660
|
+
emit(channel, data) {
|
|
661
|
+
for (const handler of handlers.get(channel) ?? []) handler(data);
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
bus.on("fm-branch-supervision:dispatch", (offer) => {
|
|
665
|
+
offers.push({ message: offer.message, heartbeat: offer.heartbeat, eligible: offer.eligible });
|
|
666
|
+
if (offer.eligible) offer.accept();
|
|
667
|
+
});
|
|
668
|
+
const pi = {
|
|
669
|
+
on() {},
|
|
670
|
+
events: bus,
|
|
671
|
+
registerCommand() {},
|
|
672
|
+
registerTool(candidate) {
|
|
673
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
674
|
+
},
|
|
675
|
+
sendUserMessage: async (message) => {
|
|
676
|
+
prompt = message;
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
680
|
+
writeFileSync(
|
|
681
|
+
`${process.env.FM_HOME}/state/.wake-queue`,
|
|
682
|
+
"1\t1\theartbeat\theartbeat\theartbeat\n2\t2\tcheck\tx-inbox\tcheck: pending x mention\n",
|
|
683
|
+
);
|
|
684
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
685
|
+
mod.default(pi);
|
|
686
|
+
await tool.execute("tool-call-heartbeat-mixed-queue", {}, undefined, undefined, {});
|
|
687
|
+
// The offer is what this asserts on, so wait for it and then give any
|
|
688
|
+
// erroneous main delivery a real chance to land before calling it absent.
|
|
689
|
+
for (let i = 0; i < 250 && offers.length === 0; i += 1) {
|
|
690
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
691
|
+
}
|
|
692
|
+
for (let i = 0; i < 25 && !prompt; i += 1) {
|
|
693
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
694
|
+
}
|
|
695
|
+
if (offers.length !== 1 || offers[0].heartbeat !== true || offers[0].eligible !== true) {
|
|
696
|
+
throw new Error(`a co-present check row made the heartbeat offer ineligible: ${JSON.stringify(offers)}`);
|
|
697
|
+
}
|
|
698
|
+
if (prompt) {
|
|
699
|
+
throw new Error(`a co-present check row rode the heartbeat into main: ${prompt}`);
|
|
700
|
+
}
|
|
701
|
+
writeFileSync(process.env.FM_STOP_FILE, "stop\n");
|
|
702
|
+
process.exit(0);
|
|
703
|
+
EOF
|
|
704
|
+
)
|
|
705
|
+
status=$?
|
|
706
|
+
expect_code 0 "$status" "a heartbeat must not ride a co-present check row into main: $out"
|
|
707
|
+
[ -z "$out" ] || fail "Pi mixed heartbeat-queue test printed output: $out"
|
|
708
|
+
pass "a co-present check row neither vetoes nor rides a heartbeat into main"
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
# Every check the main session alone can act on stays on main, even when an
|
|
712
|
+
# unrelated task-local row in the same queue is perfectly branch-eligible. The
|
|
713
|
+
# no-op checks the branch never sees are suppressed at their source instead
|
|
714
|
+
# (bin/fm-procevent-lavish.sh "silent"), so what remains under a `check:`
|
|
715
|
+
# trigger is exactly the main-only set, and each named class is driven here
|
|
716
|
+
# through the real dispatcher.
|
|
717
|
+
test_pi_main_only_check_classes_stay_on_main() {
|
|
718
|
+
local repo home plugin log stop out status reason label
|
|
719
|
+
repo="$TMP_ROOT/pi-main-only-check-root"
|
|
720
|
+
home="$TMP_ROOT/pi-main-only-check-home"
|
|
721
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config" "$home/projects/approved"
|
|
722
|
+
install_pi_watch_extension_fixture "$repo"
|
|
723
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
724
|
+
printf 'project=%s/projects/approved\nwindow=fm-window\n' "$home" > "$home/state/task-a.meta"
|
|
725
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
726
|
+
#!/usr/bin/env bash
|
|
727
|
+
if [ "${1:-}" = --handling-delivered ]; then exit 0; fi
|
|
728
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
729
|
+
count=$(grep -c '^arm=' "$FM_ARM_LOG")
|
|
730
|
+
if [ "$count" -eq 1 ]; then
|
|
731
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
732
|
+
printf '%s\n' "${FM_TEST_REASON:?}"
|
|
733
|
+
exit 0
|
|
734
|
+
fi
|
|
735
|
+
printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$"
|
|
736
|
+
trap 'exit 0' TERM INT
|
|
737
|
+
while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
|
|
738
|
+
SH
|
|
739
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
740
|
+
while IFS='|' read -r label reason; do
|
|
741
|
+
[ -n "$label" ] || continue
|
|
742
|
+
log="$TMP_ROOT/pi-main-only-check-$label.log"
|
|
743
|
+
stop="$TMP_ROOT/pi-main-only-check-$label.stop"
|
|
744
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" \
|
|
745
|
+
FM_TEST_REASON="$reason" node --input-type=module 2>&1 <<'EOF'
|
|
746
|
+
import { writeFileSync } from "node:fs";
|
|
747
|
+
import { pathToFileURL } from "node:url";
|
|
748
|
+
|
|
749
|
+
const offers = [];
|
|
750
|
+
let prompt = "";
|
|
751
|
+
let tool = null;
|
|
752
|
+
const handlers = new Map();
|
|
753
|
+
const bus = {
|
|
754
|
+
on(channel, handler) {
|
|
755
|
+
handlers.set(channel, [...(handlers.get(channel) ?? []), handler]);
|
|
756
|
+
return () => {};
|
|
757
|
+
},
|
|
758
|
+
emit(channel, data) {
|
|
759
|
+
for (const handler of handlers.get(channel) ?? []) handler(data);
|
|
760
|
+
},
|
|
761
|
+
};
|
|
762
|
+
bus.on("fm-branch-supervision:dispatch", (offer) => {
|
|
763
|
+
offers.push({ message: offer.message, eligible: offer.eligible });
|
|
764
|
+
if (offer.eligible) offer.accept();
|
|
765
|
+
});
|
|
766
|
+
const pi = {
|
|
767
|
+
on() {},
|
|
768
|
+
events: bus,
|
|
769
|
+
registerCommand() {},
|
|
770
|
+
registerTool(candidate) {
|
|
771
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
772
|
+
},
|
|
773
|
+
sendUserMessage: async (message) => {
|
|
774
|
+
prompt = message;
|
|
775
|
+
},
|
|
776
|
+
};
|
|
777
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
778
|
+
// A task-local row the branch would happily take sits in the same queue, so
|
|
779
|
+
// only the check-kind TRIGGER itself can be what keeps this wake on main.
|
|
780
|
+
writeFileSync(
|
|
781
|
+
`${process.env.FM_HOME}/state/.wake-queue`,
|
|
782
|
+
"1\t1\tsignal\ttask-a.status\tsignal: task-a.status\n2\t2\tcheck\tmain-only\t" + process.env.FM_TEST_REASON + "\n",
|
|
783
|
+
);
|
|
784
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
785
|
+
mod.default(pi);
|
|
786
|
+
await tool.execute("tool-call-main-only-check", {}, undefined, undefined, {});
|
|
787
|
+
for (let i = 0; i < 250 && !prompt; i += 1) {
|
|
788
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
789
|
+
}
|
|
790
|
+
if (offers.length !== 1 || offers[0].eligible !== false) {
|
|
791
|
+
throw new Error(`a main-only check was offered to the branch: ${JSON.stringify(offers)}`);
|
|
792
|
+
}
|
|
793
|
+
if (!prompt.includes(`FIRSTMATE WATCHER WAKE: ${process.env.FM_TEST_REASON}`)) {
|
|
794
|
+
throw new Error(`a main-only check did not reach main: ${prompt}`);
|
|
795
|
+
}
|
|
796
|
+
writeFileSync(process.env.FM_STOP_FILE, "stop\n");
|
|
797
|
+
process.exit(0);
|
|
798
|
+
EOF
|
|
799
|
+
)
|
|
800
|
+
status=$?
|
|
801
|
+
expect_code 0 "$status" "the $label check class must stay on main: $out"
|
|
802
|
+
[ -z "$out" ] || fail "Pi main-only check test ($label) printed output: $out"
|
|
803
|
+
done <<'CLASSES'
|
|
804
|
+
relay-mention|check: x-mention 1234567890
|
|
805
|
+
credential-failure|check: gh auth check failed; re-authenticate before dispatch
|
|
806
|
+
merge-confirmation|check: task-a.check.sh: PR merged
|
|
807
|
+
real-board-answer|check: procevent lavish lavish-abcdef0123456789 1
|
|
808
|
+
CLASSES
|
|
809
|
+
pass "every main-only check class still reaches main, never the supervision branch"
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
test_pi_heartbeat_restoration_failure_stays_on_main() {
|
|
813
|
+
local repo home plugin log out status
|
|
814
|
+
repo="$TMP_ROOT/pi-heartbeat-restoration-failure-root"
|
|
815
|
+
home="$TMP_ROOT/pi-heartbeat-restoration-failure-home"
|
|
816
|
+
log="$TMP_ROOT/pi-heartbeat-restoration-failure.log"
|
|
817
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
818
|
+
install_pi_watch_extension_fixture "$repo"
|
|
819
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
820
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
821
|
+
#!/usr/bin/env bash
|
|
822
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
823
|
+
count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
|
|
824
|
+
if [ "$count" -eq 1 ]; then
|
|
825
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
826
|
+
printf 'heartbeat\n'
|
|
827
|
+
exit 0
|
|
828
|
+
fi
|
|
829
|
+
printf 'synthetic successor startup failure\n' >&2
|
|
830
|
+
exit 1
|
|
831
|
+
SH
|
|
832
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
833
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
|
|
834
|
+
import { writeFileSync } from "node:fs";
|
|
835
|
+
import { pathToFileURL } from "node:url";
|
|
836
|
+
|
|
837
|
+
const offers = [];
|
|
838
|
+
let prompt = "";
|
|
839
|
+
let tool = null;
|
|
840
|
+
const handlers = new Map();
|
|
841
|
+
const bus = {
|
|
842
|
+
on(channel, handler) {
|
|
843
|
+
handlers.set(channel, [...(handlers.get(channel) ?? []), handler]);
|
|
844
|
+
return () => {};
|
|
845
|
+
},
|
|
846
|
+
emit(channel, data) {
|
|
847
|
+
for (const handler of handlers.get(channel) ?? []) handler(data);
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
bus.on("fm-branch-supervision:dispatch", (offer) => {
|
|
851
|
+
offers.push({ message: offer.message, heartbeat: offer.heartbeat });
|
|
852
|
+
offer.accept();
|
|
853
|
+
});
|
|
854
|
+
const pi = {
|
|
855
|
+
on() {},
|
|
856
|
+
events: bus,
|
|
857
|
+
registerCommand() {},
|
|
858
|
+
registerTool(candidate) {
|
|
859
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
860
|
+
},
|
|
861
|
+
sendUserMessage: async (message) => {
|
|
862
|
+
prompt = message;
|
|
863
|
+
},
|
|
864
|
+
};
|
|
865
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
866
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
867
|
+
mod.default(pi);
|
|
868
|
+
await tool.execute("tool-call-heartbeat-restoration-failure", {}, undefined, undefined, {});
|
|
869
|
+
for (let i = 0; i < 500 && !prompt; i += 1) {
|
|
870
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
871
|
+
}
|
|
872
|
+
if (offers.length !== 0) {
|
|
873
|
+
throw new Error(`heartbeat restoration failure was offered to the branch: ${JSON.stringify(offers)}`);
|
|
874
|
+
}
|
|
875
|
+
if (!prompt.includes("FIRSTMATE WATCHER WAKE: heartbeat\n\nwatcher: FAILED")) {
|
|
876
|
+
throw new Error(`main wake lost the bare heartbeat reason: ${prompt}`);
|
|
877
|
+
}
|
|
878
|
+
if (!prompt.includes("watcher: FAILED - Pi extension could not restore watcher continuity after 2 retries")) {
|
|
879
|
+
throw new Error(`main wake lost the restoration failure: ${prompt}`);
|
|
880
|
+
}
|
|
881
|
+
EOF
|
|
882
|
+
)
|
|
883
|
+
status=$?
|
|
884
|
+
expect_code 0 "$status" "heartbeat restoration failure must bypass an accepting branch: $out"
|
|
885
|
+
[ -z "$out" ] || fail "Pi heartbeat restoration-failure test printed output: $out"
|
|
886
|
+
pass "heartbeat restoration failure stays on main"
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
test_pi_watcher_failure_never_offered_to_branch() {
|
|
890
|
+
local repo home plugin out status
|
|
891
|
+
repo="$TMP_ROOT/pi-watcher-failure-root"
|
|
892
|
+
home="$TMP_ROOT/pi-watcher-failure-home"
|
|
893
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
894
|
+
install_pi_watch_extension_fixture "$repo"
|
|
895
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
896
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
897
|
+
#!/usr/bin/env bash
|
|
898
|
+
printf 'watcher: healthy pid=1 (beacon 0s)\n'
|
|
899
|
+
SH
|
|
900
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
901
|
+
# Only "actionable" closes (signal/stale/check/heartbeat) ever reach
|
|
902
|
+
# offerWakeToBranch; only main can repair the watcher cycle
|
|
903
|
+
# (docs/pi-supervision-branch.md), and default-on eligibility must not
|
|
904
|
+
# change that. A live, always-accepting bus listener proves the negative:
|
|
905
|
+
# even with an acceptor present, a watcher-failure close is never offered.
|
|
906
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
|
|
907
|
+
import { writeFileSync } from "node:fs";
|
|
908
|
+
import { pathToFileURL } from "node:url";
|
|
909
|
+
|
|
910
|
+
const offers = [];
|
|
911
|
+
let prompt = "";
|
|
912
|
+
let handler = null;
|
|
913
|
+
const handlers = new Map();
|
|
914
|
+
const bus = {
|
|
915
|
+
on(channel, h) {
|
|
916
|
+
handlers.set(channel, [...(handlers.get(channel) ?? []), h]);
|
|
917
|
+
return () => {};
|
|
918
|
+
},
|
|
919
|
+
emit(channel, data) {
|
|
920
|
+
for (const h of handlers.get(channel) ?? []) h(data);
|
|
921
|
+
},
|
|
922
|
+
};
|
|
923
|
+
bus.on("fm-branch-supervision:dispatch", (offer) => {
|
|
924
|
+
offers.push({ message: offer.message, heartbeat: offer.heartbeat });
|
|
925
|
+
offer.accept();
|
|
926
|
+
});
|
|
927
|
+
const pi = {
|
|
928
|
+
on() {},
|
|
929
|
+
events: bus,
|
|
930
|
+
registerCommand(name, options) {
|
|
931
|
+
if (name === "fm-watch-arm-pi") handler = options.handler;
|
|
932
|
+
},
|
|
933
|
+
registerTool() {},
|
|
934
|
+
sendUserMessage: async (message) => {
|
|
935
|
+
prompt = message;
|
|
936
|
+
},
|
|
937
|
+
};
|
|
938
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
939
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
940
|
+
mod.default(pi);
|
|
941
|
+
await handler("", { ui: { notify() {} } });
|
|
942
|
+
for (let i = 0; i < 250 && !prompt; i += 1) {
|
|
943
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
944
|
+
}
|
|
945
|
+
if (!prompt.includes("external healthy watcher")) {
|
|
946
|
+
throw new Error(`watcher failure did not reach main: ${prompt}`);
|
|
947
|
+
}
|
|
948
|
+
if (offers.length !== 0) {
|
|
949
|
+
throw new Error(`watcher failure was offered to the branch: ${JSON.stringify(offers)}`);
|
|
950
|
+
}
|
|
951
|
+
EOF
|
|
952
|
+
)
|
|
953
|
+
status=$?
|
|
954
|
+
expect_code 0 "$status" "watcher-failure repair must never be offered to the branch: $out"
|
|
955
|
+
[ -z "$out" ] || fail "Pi watcher-failure test printed output: $out"
|
|
956
|
+
pass "watcher-failure repair stays with main even with a live, accepting branch listener"
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
test_pi_handling_delivery_failure_is_typed_once() {
|
|
960
|
+
local repo home plugin log stop out status
|
|
961
|
+
repo="$TMP_ROOT/pi-handling-fail-root"
|
|
962
|
+
home="$TMP_ROOT/pi-handling-fail-home"
|
|
963
|
+
log="$TMP_ROOT/pi-handling-fail.log"
|
|
964
|
+
stop="$TMP_ROOT/pi-handling-fail.stop"
|
|
965
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
966
|
+
install_pi_watch_extension_fixture "$repo"
|
|
967
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
968
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
969
|
+
#!/usr/bin/env bash
|
|
970
|
+
if [ "${1:-}" = --handling-delivered ]; then
|
|
971
|
+
printf 'refused generation=%s watcher=%s\n' "$2" "$4" >> "${FM_ARM_LOG:?}"
|
|
972
|
+
exit 1
|
|
973
|
+
fi
|
|
974
|
+
printf 'arm=%s predecessor=%s\n' "$$" "${FM_WATCH_PREDECESSOR_ARM_PID:-none}" >> "${FM_ARM_LOG:?}"
|
|
975
|
+
count=$(grep -c '^arm=' "$FM_ARM_LOG")
|
|
976
|
+
if [ "$count" -eq 1 ]; then
|
|
977
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
978
|
+
printf 'signal: synthetic actionable close\n'
|
|
979
|
+
exit 0
|
|
980
|
+
fi
|
|
981
|
+
printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$"
|
|
982
|
+
trap 'exit 0' TERM INT
|
|
983
|
+
while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
|
|
984
|
+
SH
|
|
985
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
986
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF'
|
|
987
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
988
|
+
import { pathToFileURL } from "node:url";
|
|
989
|
+
|
|
990
|
+
let tool = null;
|
|
991
|
+
let prompt = "";
|
|
992
|
+
const pi = {
|
|
993
|
+
on() {},
|
|
994
|
+
registerCommand() {},
|
|
995
|
+
registerTool(candidate) {
|
|
996
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
997
|
+
},
|
|
998
|
+
sendUserMessage: async (message) => {
|
|
999
|
+
prompt += message;
|
|
1000
|
+
},
|
|
1001
|
+
};
|
|
1002
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
1003
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
1004
|
+
mod.default(pi);
|
|
1005
|
+
await tool.execute("tool-call-handling-fail", {}, undefined, undefined, {});
|
|
1006
|
+
for (let i = 0; i < 250 && !prompt.includes("handling delivery confirmation was rejected"); i += 1) {
|
|
1007
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1008
|
+
}
|
|
1009
|
+
if (!prompt.includes("FIRSTMATE WATCHER WAKE")) throw new Error(`missing follow-up: ${prompt}`);
|
|
1010
|
+
if (!prompt.includes("handling delivery confirmation was rejected")) {
|
|
1011
|
+
throw new Error(`failed handshake was swallowed: ${prompt}`);
|
|
1012
|
+
}
|
|
1013
|
+
if ((prompt.match(/FIRSTMATE WATCHER WAKE/g) || []).length !== 1) {
|
|
1014
|
+
throw new Error(`failed handshake was not a single typed message: ${prompt}`);
|
|
1015
|
+
}
|
|
1016
|
+
const rows = existsSync(process.env.FM_ARM_LOG)
|
|
1017
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
|
|
1018
|
+
: [];
|
|
1019
|
+
if (rows.filter((row) => row.startsWith("refused ")).length < 1) {
|
|
1020
|
+
throw new Error(`handling-delivered was never attempted: ${rows.join(" | ")}`);
|
|
1021
|
+
}
|
|
1022
|
+
writeFileSync(process.env.FM_STOP_FILE, "stop\n");
|
|
1023
|
+
process.exit(0);
|
|
1024
|
+
EOF
|
|
1025
|
+
)
|
|
1026
|
+
status=$?
|
|
1027
|
+
expect_code 0 "$status" "Pi must surface a refused handling handshake as one typed failure"
|
|
1028
|
+
[ -z "$out" ] || fail "Pi handling-delivery failure test printed output: $out"
|
|
1029
|
+
pass "Pi refused handling handshake is classified and not swallowed"
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
test_pi_hung_successor_falls_back_to_typed_wake() {
|
|
1033
|
+
local repo home plugin log out status
|
|
1034
|
+
repo="$TMP_ROOT/pi-hung-successor-root"
|
|
1035
|
+
home="$TMP_ROOT/pi-hung-successor-home"
|
|
1036
|
+
log="$TMP_ROOT/pi-hung-successor.log"
|
|
1037
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
1038
|
+
install_pi_watch_extension_fixture "$repo"
|
|
1039
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
1040
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
1041
|
+
#!/usr/bin/env bash
|
|
1042
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
1043
|
+
count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
|
|
1044
|
+
if [ "$count" -eq 1 ]; then
|
|
1045
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
1046
|
+
printf 'signal: synthetic wake\n'
|
|
1047
|
+
exit 0
|
|
1048
|
+
fi
|
|
1049
|
+
trap 'exit 0' TERM INT
|
|
1050
|
+
while :; do sleep 0.02; done
|
|
1051
|
+
SH
|
|
1052
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
1053
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_PI_ARM_READY_TIMEOUT_MS="$ARM_READY_TIMEOUT_MS" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
|
|
1054
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
1055
|
+
import { pathToFileURL } from "node:url";
|
|
1056
|
+
|
|
1057
|
+
let tool = null;
|
|
1058
|
+
let prompt = "";
|
|
1059
|
+
let rowsAtPrompt = 0;
|
|
1060
|
+
const pi = {
|
|
1061
|
+
on() {},
|
|
1062
|
+
registerCommand() {},
|
|
1063
|
+
registerTool(candidate) {
|
|
1064
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
1065
|
+
},
|
|
1066
|
+
sendUserMessage: async (message) => {
|
|
1067
|
+
prompt += message;
|
|
1068
|
+
rowsAtPrompt = existsSync(process.env.FM_ARM_LOG)
|
|
1069
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n").length
|
|
1070
|
+
: 0;
|
|
1071
|
+
},
|
|
1072
|
+
};
|
|
1073
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
1074
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
1075
|
+
mod.default(pi);
|
|
1076
|
+
await tool.execute("tool-call-hung-successor", {}, undefined, undefined, {});
|
|
1077
|
+
// Three unready successors each cost the full readiness budget, so wait well
|
|
1078
|
+
// past their sum. The wait ends as soon as the wake lands.
|
|
1079
|
+
for (let i = 0; i < 1500 && !prompt; i += 1) {
|
|
1080
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1081
|
+
}
|
|
1082
|
+
const rows = existsSync(process.env.FM_ARM_LOG)
|
|
1083
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
|
|
1084
|
+
: [];
|
|
1085
|
+
if (rows.length !== 4) throw new Error(`expected one successor plus two retries, got ${rows.length}: ${rows.join(" | ")}`);
|
|
1086
|
+
if (rowsAtPrompt !== 4) throw new Error(`wake arrived before restoration exhausted (${rowsAtPrompt} arm rows)`);
|
|
1087
|
+
if (!prompt.includes("signal: synthetic wake")) throw new Error(`original wake was lost: ${prompt}`);
|
|
1088
|
+
if (!prompt.includes("could not restore watcher continuity after 2 retries")) throw new Error(`missing typed restoration failure: ${prompt}`);
|
|
1089
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1090
|
+
const stableRows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
1091
|
+
if (stableRows.length !== 4) throw new Error(`single-flight recovery launched ${stableRows.length} arms`);
|
|
1092
|
+
EOF
|
|
1093
|
+
)
|
|
1094
|
+
status=$?
|
|
1095
|
+
expect_code 0 "$status" "Pi must deliver the actionable wake after bounded hung-successor recovery"
|
|
1096
|
+
[ -z "$out" ] || fail "Pi hung-successor test printed output: $out"
|
|
1097
|
+
pass "Pi hung successor falls back to one typed actionable wake"
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
test_pi_unretired_successor_falls_back_without_retry() {
|
|
1101
|
+
local repo home plugin log release out status
|
|
1102
|
+
repo="$TMP_ROOT/pi-unretired-successor-root"
|
|
1103
|
+
home="$TMP_ROOT/pi-unretired-successor-home"
|
|
1104
|
+
log="$TMP_ROOT/pi-unretired-successor.log"
|
|
1105
|
+
release="$TMP_ROOT/pi-unretired-successor.release"
|
|
1106
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
1107
|
+
install_pi_watch_extension_fixture "$repo"
|
|
1108
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
1109
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
1110
|
+
#!/usr/bin/env bash
|
|
1111
|
+
if [ -f "$FM_ARM_LOG" ]; then
|
|
1112
|
+
count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
|
|
1113
|
+
else
|
|
1114
|
+
count=0
|
|
1115
|
+
fi
|
|
1116
|
+
if [ "$count" -eq 0 ]; then
|
|
1117
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
1118
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
1119
|
+
printf 'signal: synthetic wake\n'
|
|
1120
|
+
exit 0
|
|
1121
|
+
fi
|
|
1122
|
+
trap '' TERM INT
|
|
1123
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
1124
|
+
while [ ! -e "$FM_RELEASE_FILE" ]; do sleep 0.1; done
|
|
1125
|
+
SH
|
|
1126
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
1127
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_RELEASE_FILE="$release" FM_PI_ARM_READY_TIMEOUT_MS="$ARM_READY_TIMEOUT_MS" FM_WATCH_ARM_RETIRE_TIMEOUT_MS=20 FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
|
|
1128
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
1129
|
+
import { pathToFileURL } from "node:url";
|
|
1130
|
+
|
|
1131
|
+
let tool = null;
|
|
1132
|
+
let prompt = "";
|
|
1133
|
+
let rowsAtPrompt = 0;
|
|
1134
|
+
const pi = {
|
|
1135
|
+
on() {},
|
|
1136
|
+
registerCommand() {},
|
|
1137
|
+
registerTool(candidate) {
|
|
1138
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
1139
|
+
},
|
|
1140
|
+
sendUserMessage: async (message) => {
|
|
1141
|
+
prompt += message;
|
|
1142
|
+
rowsAtPrompt = existsSync(process.env.FM_ARM_LOG)
|
|
1143
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n").length
|
|
1144
|
+
: 0;
|
|
1145
|
+
},
|
|
1146
|
+
};
|
|
1147
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
1148
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
1149
|
+
mod.default(pi);
|
|
1150
|
+
await tool.execute("tool-call-unretired-successor", {}, undefined, undefined, {});
|
|
1151
|
+
for (let i = 0; i < 500 && !prompt; i += 1) {
|
|
1152
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1153
|
+
}
|
|
1154
|
+
const rows = existsSync(process.env.FM_ARM_LOG)
|
|
1155
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
|
|
1156
|
+
: [];
|
|
1157
|
+
if (rows.length !== 2) throw new Error(`unretired arm overlapped a retry: ${rows.join(" | ")}`);
|
|
1158
|
+
if (rowsAtPrompt !== 2) throw new Error(`wake arrived after an overlapping retry (${rowsAtPrompt} arm rows)`);
|
|
1159
|
+
if (!prompt.includes("signal: synthetic wake")) throw new Error(`original wake was lost: ${prompt}`);
|
|
1160
|
+
if (!prompt.includes("unready successor arm did not exit within 20ms")) throw new Error(`missing unretired-arm failure: ${prompt}`);
|
|
1161
|
+
writeFileSync(process.env.FM_RELEASE_FILE, "release\n");
|
|
1162
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
1163
|
+
EOF
|
|
1164
|
+
)
|
|
1165
|
+
status=$?
|
|
1166
|
+
expect_code 0 "$status" "Pi must fall back without overlapping an unretired successor"
|
|
1167
|
+
[ -z "$out" ] || fail "Pi unretired-successor test printed output: $out"
|
|
1168
|
+
pass "Pi unretired successor falls back without an overlapping retry"
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
test_pi_late_unretired_close_resumes_supervision() {
|
|
1172
|
+
local kind repo home plugin log ready retired release stop out status
|
|
1173
|
+
for kind in actionable non-actionable; do
|
|
1174
|
+
repo="$TMP_ROOT/pi-late-$kind-root"
|
|
1175
|
+
home="$TMP_ROOT/pi-late-$kind-home"
|
|
1176
|
+
log="$TMP_ROOT/pi-late-$kind.log"
|
|
1177
|
+
ready="$TMP_ROOT/pi-late-$kind.ready"
|
|
1178
|
+
retired="$TMP_ROOT/pi-late-$kind.retired"
|
|
1179
|
+
release="$TMP_ROOT/pi-late-$kind.release"
|
|
1180
|
+
stop="$TMP_ROOT/pi-late-$kind.stop"
|
|
1181
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
1182
|
+
install_pi_watch_extension_fixture "$repo"
|
|
1183
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
1184
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
1185
|
+
#!/usr/bin/env bash
|
|
1186
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
1187
|
+
count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
|
|
1188
|
+
if [ "$count" -eq 1 ]; then
|
|
1189
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
1190
|
+
printf 'signal: original wake\n'
|
|
1191
|
+
exit 0
|
|
1192
|
+
fi
|
|
1193
|
+
if [ "$count" -eq 2 ]; then
|
|
1194
|
+
trap 'printf "retired\\n" > "${FM_UNRETIRED_RETIRE_FILE:?}"' TERM INT
|
|
1195
|
+
printf 'ready\n' > "${FM_UNRETIRED_READY_FILE:?}"
|
|
1196
|
+
while [ ! -e "$FM_RELEASE_FILE" ]; do sleep 0.02; done
|
|
1197
|
+
[ "$FM_LATE_KIND" = actionable ] && printf 'signal: late wake\n'
|
|
1198
|
+
exit 0
|
|
1199
|
+
fi
|
|
1200
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
1201
|
+
trap 'exit 0' TERM INT
|
|
1202
|
+
while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
|
|
1203
|
+
SH
|
|
1204
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
1205
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_UNRETIRED_READY_FILE="$ready" FM_UNRETIRED_RETIRE_FILE="$retired" FM_RELEASE_FILE="$release" FM_STOP_FILE="$stop" FM_LATE_KIND="$kind" FM_PI_ARM_READY_TIMEOUT_MS="$ARM_READY_TIMEOUT_MS" FM_WATCH_ARM_RETIRE_TIMEOUT_MS=20 FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
|
|
1206
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
1207
|
+
import { pathToFileURL } from "node:url";
|
|
1208
|
+
|
|
1209
|
+
let tool = null;
|
|
1210
|
+
const prompts = [];
|
|
1211
|
+
const pi = {
|
|
1212
|
+
on() {},
|
|
1213
|
+
registerCommand() {},
|
|
1214
|
+
registerTool(candidate) {
|
|
1215
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
1216
|
+
},
|
|
1217
|
+
sendUserMessage: async (message) => {
|
|
1218
|
+
prompts.push(message);
|
|
1219
|
+
},
|
|
1220
|
+
};
|
|
1221
|
+
const rows = () => existsSync(process.env.FM_ARM_LOG)
|
|
1222
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
|
|
1223
|
+
: [];
|
|
1224
|
+
async function waitFor(predicate, message) {
|
|
1225
|
+
for (let i = 0; i < 500; i += 1) {
|
|
1226
|
+
if (predicate()) return;
|
|
1227
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1228
|
+
}
|
|
1229
|
+
throw new Error(message);
|
|
1230
|
+
}
|
|
1231
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
1232
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
1233
|
+
mod.default(pi);
|
|
1234
|
+
await tool.execute("tool-call-late-close", {}, undefined, undefined, {});
|
|
1235
|
+
await waitFor(
|
|
1236
|
+
() => existsSync(process.env.FM_UNRETIRED_READY_FILE),
|
|
1237
|
+
"unretired successor did not enter its retirement wait",
|
|
1238
|
+
);
|
|
1239
|
+
await waitFor(() => prompts.length >= 1, "original fallback was not delivered");
|
|
1240
|
+
await waitFor(
|
|
1241
|
+
() => existsSync(process.env.FM_UNRETIRED_RETIRE_FILE),
|
|
1242
|
+
"unretired successor was not asked to retire before fallback",
|
|
1243
|
+
);
|
|
1244
|
+
if (rows().length !== 2) throw new Error(`unretired arm overlapped before fallback: ${rows().join(" | ")}`);
|
|
1245
|
+
if (!prompts[0]?.includes("original wake")) throw new Error(`missing original fallback: ${prompts.join(" | ")}`);
|
|
1246
|
+
writeFileSync(process.env.FM_RELEASE_FILE, "release\n");
|
|
1247
|
+
for (let i = 0; i < 500; i += 1) {
|
|
1248
|
+
if (rows().length >= 3 && (process.env.FM_LATE_KIND !== "actionable" || prompts.some((message) => message.includes("late wake")))) break;
|
|
1249
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1250
|
+
}
|
|
1251
|
+
if (rows().length !== 3) throw new Error(`late close did not restore one successor: ${rows().join(" | ")}`);
|
|
1252
|
+
if (process.env.FM_LATE_KIND === "actionable") {
|
|
1253
|
+
if (prompts.length !== 2 || !prompts[1].includes("late wake")) throw new Error(`late actionable close was not delivered: ${prompts.join(" | ")}`);
|
|
1254
|
+
} else if (prompts.length !== 1) {
|
|
1255
|
+
throw new Error(`late non-actionable close sent an extra wake: ${prompts.join(" | ")}`);
|
|
1256
|
+
}
|
|
1257
|
+
writeFileSync(process.env.FM_STOP_FILE, "stop\n");
|
|
1258
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
1259
|
+
EOF
|
|
1260
|
+
)
|
|
1261
|
+
status=$?
|
|
1262
|
+
expect_code 0 "$status" "Pi late $kind close must remain supervised after fallback"
|
|
1263
|
+
[ -z "$out" ] || fail "Pi late-$kind test printed output: $out"
|
|
1264
|
+
done
|
|
1265
|
+
pass "Pi late unretired closes resume classified supervision"
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
test_pi_empty_close_retries_instead_of_disappearing() {
|
|
1269
|
+
local repo home plugin log stop out status
|
|
1270
|
+
repo="$TMP_ROOT/pi-empty-close-root"
|
|
1271
|
+
home="$TMP_ROOT/pi-empty-close-home"
|
|
1272
|
+
log="$TMP_ROOT/pi-empty-close.log"
|
|
1273
|
+
stop="$TMP_ROOT/pi-empty-close.stop"
|
|
1274
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
1275
|
+
install_pi_watch_extension_fixture "$repo"
|
|
1276
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
1277
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
1278
|
+
#!/usr/bin/env bash
|
|
1279
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
1280
|
+
count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
|
|
1281
|
+
if [ "$count" -eq 1 ]; then exit 0; fi
|
|
1282
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
1283
|
+
trap 'exit 0' TERM INT
|
|
1284
|
+
while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
|
|
1285
|
+
SH
|
|
1286
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
1287
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
|
|
1288
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
1289
|
+
import { pathToFileURL } from "node:url";
|
|
1290
|
+
|
|
1291
|
+
let tool = null;
|
|
1292
|
+
let prompts = 0;
|
|
1293
|
+
const pi = {
|
|
1294
|
+
on() {},
|
|
1295
|
+
registerCommand() {},
|
|
1296
|
+
registerTool(candidate) {
|
|
1297
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
1298
|
+
},
|
|
1299
|
+
sendUserMessage: async () => {
|
|
1300
|
+
prompts += 1;
|
|
1301
|
+
},
|
|
1302
|
+
};
|
|
1303
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
1304
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
1305
|
+
mod.default(pi);
|
|
1306
|
+
await tool.execute("tool-call-empty", {}, undefined, undefined, {});
|
|
1307
|
+
for (let i = 0; i < 250; i += 1) {
|
|
1308
|
+
const rows = existsSync(process.env.FM_ARM_LOG)
|
|
1309
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
|
|
1310
|
+
: [];
|
|
1311
|
+
if (rows.length >= 2) break;
|
|
1312
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1313
|
+
}
|
|
1314
|
+
const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
1315
|
+
if (rows.length !== 2) throw new Error(`clean empty close was ignored: ${rows.join(" | ")}`);
|
|
1316
|
+
if (prompts !== 0) throw new Error(`restored transient close surfaced ${prompts} failure prompts`);
|
|
1317
|
+
writeFileSync(process.env.FM_STOP_FILE, "stop\n");
|
|
1318
|
+
process.exit(0);
|
|
1319
|
+
EOF
|
|
1320
|
+
)
|
|
1321
|
+
status=$?
|
|
1322
|
+
expect_code 0 "$status" "Pi clean empty close must trigger a bounded continuity retry"
|
|
1323
|
+
[ -z "$out" ] || fail "Pi empty-close retry test printed output: $out"
|
|
1324
|
+
pass "Pi clean empty close triggers a bounded continuity retry"
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
test_pi_established_empty_close_honors_retry_limit() {
|
|
1328
|
+
local repo home plugin log out status
|
|
1329
|
+
repo="$TMP_ROOT/pi-established-empty-close-root"
|
|
1330
|
+
home="$TMP_ROOT/pi-established-empty-close-home"
|
|
1331
|
+
log="$TMP_ROOT/pi-established-empty-close.log"
|
|
1332
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
1333
|
+
install_pi_watch_extension_fixture "$repo"
|
|
1334
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
1335
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
1336
|
+
#!/usr/bin/env bash
|
|
1337
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
1338
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
1339
|
+
exit 0
|
|
1340
|
+
SH
|
|
1341
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
1342
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
|
|
1343
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
1344
|
+
import { pathToFileURL } from "node:url";
|
|
1345
|
+
|
|
1346
|
+
let tool = null;
|
|
1347
|
+
let prompt = "";
|
|
1348
|
+
const pi = {
|
|
1349
|
+
on() {},
|
|
1350
|
+
registerCommand() {},
|
|
1351
|
+
registerTool(candidate) {
|
|
1352
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
1353
|
+
},
|
|
1354
|
+
sendUserMessage: async (message) => {
|
|
1355
|
+
prompt += message;
|
|
1356
|
+
},
|
|
1357
|
+
};
|
|
1358
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
1359
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
1360
|
+
mod.default(pi);
|
|
1361
|
+
await tool.execute("tool-call-established-empty", {}, undefined, undefined, {});
|
|
1362
|
+
for (let i = 0; i < 250 && !prompt; i += 1) {
|
|
1363
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1364
|
+
}
|
|
1365
|
+
const rows = existsSync(process.env.FM_ARM_LOG)
|
|
1366
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
|
|
1367
|
+
: [];
|
|
1368
|
+
if (rows.length !== 3) throw new Error(`retry limit launched ${rows.length} arm cycles: ${rows.join(" | ")}`);
|
|
1369
|
+
if (!prompt.includes("after 2 retries")) throw new Error(`retry exhaustion was not surfaced: ${prompt}`);
|
|
1370
|
+
EOF
|
|
1371
|
+
)
|
|
1372
|
+
status=$?
|
|
1373
|
+
expect_code 0 "$status" "Pi established clean closes must honor the continuity retry limit"
|
|
1374
|
+
[ -z "$out" ] || fail "Pi established-empty-close retry test printed output: $out"
|
|
1375
|
+
pass "Pi established clean closes stop at the configured retry limit"
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
test_pi_actionable_close_rechecks_session_lock() {
|
|
1379
|
+
local repo home plugin log release out status
|
|
1380
|
+
repo="$TMP_ROOT/pi-close-lock-root"
|
|
1381
|
+
home="$TMP_ROOT/pi-close-lock-home"
|
|
1382
|
+
log="$TMP_ROOT/pi-close-lock.log"
|
|
1383
|
+
release="$TMP_ROOT/pi-close-lock.release"
|
|
1384
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
1385
|
+
install_pi_watch_extension_fixture "$repo"
|
|
1386
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
1387
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
1388
|
+
#!/usr/bin/env bash
|
|
1389
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
1390
|
+
while [ ! -e "$FM_RELEASE_FILE" ]; do sleep 0.02; done
|
|
1391
|
+
printf 'signal: lock handoff\n'
|
|
1392
|
+
SH
|
|
1393
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
1394
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_RELEASE_FILE="$release" node --input-type=module 2>&1 <<'EOF'
|
|
1395
|
+
import { spawn } from "node:child_process";
|
|
1396
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
1397
|
+
import { pathToFileURL } from "node:url";
|
|
1398
|
+
|
|
1399
|
+
let tool = null;
|
|
1400
|
+
let prompt = "";
|
|
1401
|
+
const pi = {
|
|
1402
|
+
on() {},
|
|
1403
|
+
registerCommand() {},
|
|
1404
|
+
registerTool(candidate) {
|
|
1405
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
1406
|
+
},
|
|
1407
|
+
sendUserMessage: async (message) => {
|
|
1408
|
+
prompt += message;
|
|
1409
|
+
},
|
|
1410
|
+
};
|
|
1411
|
+
const lock = `${process.env.FM_HOME}/state/.lock`;
|
|
1412
|
+
writeFileSync(lock, `${process.pid}\n`);
|
|
1413
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
1414
|
+
mod.default(pi);
|
|
1415
|
+
await tool.execute("tool-call-lock-close", {}, undefined, undefined, {});
|
|
1416
|
+
const other = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
|
|
1417
|
+
try {
|
|
1418
|
+
writeFileSync(lock, `${other.pid}\n`);
|
|
1419
|
+
writeFileSync(process.env.FM_RELEASE_FILE, "release\n");
|
|
1420
|
+
for (let i = 0; i < 250 && !prompt.includes("no longer owns the lock"); i += 1) {
|
|
1421
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1422
|
+
}
|
|
1423
|
+
const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
1424
|
+
if (rows.length !== 1) throw new Error(`successor launched after lock loss: ${rows.join(" | ")}`);
|
|
1425
|
+
if (!prompt.includes("no longer owns the lock")) throw new Error(`missing lock-loss failure: ${prompt}`);
|
|
1426
|
+
} finally {
|
|
1427
|
+
other.kill("SIGTERM");
|
|
1428
|
+
}
|
|
1429
|
+
EOF
|
|
1430
|
+
)
|
|
1431
|
+
status=$?
|
|
1432
|
+
[ "$status" -eq 0 ] || fail "Pi close handler must verify session-lock ownership before successor launch: $out"
|
|
1433
|
+
[ -z "$out" ] || fail "Pi close lock test printed output: $out"
|
|
1434
|
+
pass "Pi close handler verifies session-lock ownership before successor launch"
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
test_pi_arm_distinguishes_session_lock_ownership() {
|
|
1438
|
+
local repo home plugin log out status
|
|
1439
|
+
repo="$TMP_ROOT/pi-lock-ownership-root"
|
|
1440
|
+
home="$TMP_ROOT/pi-lock-ownership-home"
|
|
1441
|
+
log="$TMP_ROOT/pi-lock-ownership.log"
|
|
1442
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
1443
|
+
install_pi_watch_extension_fixture "$repo"
|
|
1444
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
1445
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
1446
|
+
#!/usr/bin/env bash
|
|
1447
|
+
printf 'arm\n' >> "${FM_ARM_LOG:?}"
|
|
1448
|
+
SH
|
|
1449
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
1450
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" node --input-type=module 2>&1 <<'EOF'
|
|
1451
|
+
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
|
|
1452
|
+
import { spawn } from "node:child_process";
|
|
1453
|
+
import { pathToFileURL } from "node:url";
|
|
1454
|
+
|
|
1455
|
+
let tool = null;
|
|
1456
|
+
const pi = {
|
|
1457
|
+
on() {},
|
|
1458
|
+
registerCommand() {},
|
|
1459
|
+
registerTool(candidate) {
|
|
1460
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
1461
|
+
},
|
|
1462
|
+
sendUserMessage: async () => {},
|
|
1463
|
+
};
|
|
1464
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
1465
|
+
mod.default(pi);
|
|
1466
|
+
if (!tool) throw new Error("Pi watch tool was not registered");
|
|
1467
|
+
|
|
1468
|
+
const lock = `${process.env.FM_HOME}/state/.lock`;
|
|
1469
|
+
const callArm = () => tool.execute("tool-call-lock", {}, undefined, undefined, {});
|
|
1470
|
+
const assertMissingLock = (result, label) => {
|
|
1471
|
+
if (result.details?.ok !== false) throw new Error(`${label} unexpectedly armed: ${JSON.stringify(result.details)}`);
|
|
1472
|
+
if (!result.details.message.includes("no live session holds the lock")) {
|
|
1473
|
+
throw new Error(`${label} missing no-live-session guidance: ${result.details.message}`);
|
|
1474
|
+
}
|
|
1475
|
+
if (!result.details.message.includes("bin/fm-session-start.sh") || !result.details.message.includes("re-arm")) {
|
|
1476
|
+
throw new Error(`${label} missing reclaim and re-arm guidance: ${result.details.message}`);
|
|
1477
|
+
}
|
|
1478
|
+
if (result.details.message.includes("held by another firstmate session")) {
|
|
1479
|
+
throw new Error(`${label} was misreported as a live other holder: ${result.details.message}`);
|
|
1480
|
+
}
|
|
1481
|
+
};
|
|
1482
|
+
|
|
1483
|
+
if (existsSync(lock)) unlinkSync(lock);
|
|
1484
|
+
assertMissingLock(await callArm(), "absent lock");
|
|
1485
|
+
writeFileSync(lock, "999999\n");
|
|
1486
|
+
assertMissingLock(await callArm(), "dead lock holder");
|
|
1487
|
+
|
|
1488
|
+
const other = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
|
|
1489
|
+
try {
|
|
1490
|
+
writeFileSync(lock, `${other.pid}\n`);
|
|
1491
|
+
const liveOther = await callArm();
|
|
1492
|
+
if (liveOther.details?.ok !== false) throw new Error(`live other holder unexpectedly armed: ${JSON.stringify(liveOther.details)}`);
|
|
1493
|
+
if (liveOther.details.message !== "watcher: read-only - session lock is held by another firstmate session") {
|
|
1494
|
+
throw new Error(`unexpected live-other response: ${liveOther.details.message}`);
|
|
1495
|
+
}
|
|
1496
|
+
} finally {
|
|
1497
|
+
other.kill("SIGTERM");
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
if (existsSync(process.env.FM_ARM_LOG)) throw new Error("watcher arm ran without lock ownership");
|
|
1501
|
+
writeFileSync(lock, `${process.pid}\n`);
|
|
1502
|
+
const owned = await callArm();
|
|
1503
|
+
if (owned.details?.ok !== true || !owned.details.message.includes("started Pi extension arm child")) {
|
|
1504
|
+
throw new Error(`owned lock did not arm: ${JSON.stringify(owned.details)}`);
|
|
1505
|
+
}
|
|
1506
|
+
for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) {
|
|
1507
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1508
|
+
}
|
|
1509
|
+
if (!existsSync(process.env.FM_ARM_LOG)) throw new Error("owned lock did not run the watcher arm");
|
|
1510
|
+
EOF
|
|
1511
|
+
)
|
|
1512
|
+
status=$?
|
|
1513
|
+
expect_code 0 "$status" "Pi watcher arm must distinguish owned, live-other, and missing or dead session locks"
|
|
1514
|
+
[ -z "$out" ] || fail "Pi lock-ownership arm test printed output: $out"
|
|
1515
|
+
pass "Pi watcher arm distinguishes all session lock ownership states"
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
test_pi_session_transition_generation_owner() {
|
|
1519
|
+
local repo home plugin child_pid_file child_marker_file marker_root arm_log out status
|
|
1520
|
+
repo="$TMP_ROOT/pi-session-transition-root"
|
|
1521
|
+
home="$TMP_ROOT/pi-session-transition-home"
|
|
1522
|
+
child_pid_file="$TMP_ROOT/pi-session-transition-child.pid"
|
|
1523
|
+
child_marker_file="$TMP_ROOT/pi-session-transition-child.marker"
|
|
1524
|
+
marker_root="$TMP_ROOT/pi-session-transition-markers"
|
|
1525
|
+
arm_log="$TMP_ROOT/pi-session-transition-arm.log"
|
|
1526
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config" "$marker_root"
|
|
1527
|
+
install_pi_watch_extension_fixture "$repo"
|
|
1528
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
1529
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
1530
|
+
#!/usr/bin/env bash
|
|
1531
|
+
# The marker identifies this exact process lifetime after its PID is recycled.
|
|
1532
|
+
marker=$(mktemp "${FM_MARKER_ROOT:?}/arm.XXXXXX") || exit 1
|
|
1533
|
+
cleanup() { rm -f "$marker"; }
|
|
1534
|
+
trap cleanup EXIT
|
|
1535
|
+
trap 'exit 0' TERM INT
|
|
1536
|
+
printf 'watcher: started pid=%s\n' "$$"
|
|
1537
|
+
printf '%s\n' "$$" > "${FM_CHILD_PID_FILE:?}"
|
|
1538
|
+
printf '%s\n' "$marker" > "${FM_CHILD_MARKER_FILE:?}"
|
|
1539
|
+
printf 'arm pid=%s marker=%s\n' "$$" "$marker" >> "${FM_ARM_LOG:?}"
|
|
1540
|
+
while :; do sleep 0.2; done
|
|
1541
|
+
SH
|
|
1542
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
1543
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_CHILD_PID_FILE="$child_pid_file" FM_CHILD_MARKER_FILE="$child_marker_file" FM_MARKER_ROOT="$marker_root" FM_ARM_LOG="$arm_log" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF'
|
|
1544
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
1545
|
+
import { pathToFileURL } from "node:url";
|
|
1546
|
+
|
|
1547
|
+
function makePi() {
|
|
1548
|
+
const handlers = new Map();
|
|
1549
|
+
let tool = null;
|
|
1550
|
+
const pi = {
|
|
1551
|
+
on(event, handler) {
|
|
1552
|
+
handlers.set(event, handler);
|
|
1553
|
+
},
|
|
1554
|
+
registerCommand() {},
|
|
1555
|
+
registerTool(candidate) {
|
|
1556
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
1557
|
+
},
|
|
1558
|
+
sendUserMessage: async () => {},
|
|
1559
|
+
events: { on() {} },
|
|
1560
|
+
};
|
|
1561
|
+
return { pi, handlers, getTool: () => tool };
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function pidAlive(pid) {
|
|
1565
|
+
try {
|
|
1566
|
+
process.kill(Number(pid), 0);
|
|
1567
|
+
return true;
|
|
1568
|
+
} catch {
|
|
1569
|
+
return false;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
async function waitFor(pred, label, attempts = 250) {
|
|
1574
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
1575
|
+
if (pred()) return;
|
|
1576
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1577
|
+
}
|
|
1578
|
+
throw new Error(`timeout waiting for ${label}`);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function currentArm() {
|
|
1582
|
+
try {
|
|
1583
|
+
return {
|
|
1584
|
+
pid: readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim(),
|
|
1585
|
+
marker: readFileSync(process.env.FM_CHILD_MARKER_FILE, "utf8").trim(),
|
|
1586
|
+
};
|
|
1587
|
+
} catch {
|
|
1588
|
+
return { pid: "", marker: "" };
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
function liveArmPids() {
|
|
1593
|
+
if (!existsSync(process.env.FM_ARM_LOG)) return [];
|
|
1594
|
+
return readFileSync(process.env.FM_ARM_LOG, "utf8")
|
|
1595
|
+
.trim()
|
|
1596
|
+
.split(/\n/)
|
|
1597
|
+
.filter(Boolean)
|
|
1598
|
+
.map((line) => {
|
|
1599
|
+
const match = /pid=(\d+) marker=(\S+)/.exec(line);
|
|
1600
|
+
return match ? { pid: match[1], marker: match[2] } : { pid: "", marker: "" };
|
|
1601
|
+
})
|
|
1602
|
+
.filter((arm) => arm.pid && arm.marker && existsSync(arm.marker) && pidAlive(arm.pid))
|
|
1603
|
+
.map((arm) => arm.pid);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
1607
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
1608
|
+
|
|
1609
|
+
const startup = makePi();
|
|
1610
|
+
mod.default(startup.pi);
|
|
1611
|
+
await startup.handlers.get("session_start")?.({ type: "session_start", reason: "startup" }, {});
|
|
1612
|
+
const first = await startup.getTool().execute("startup", {}, undefined, undefined, {});
|
|
1613
|
+
if (!first.details?.ok || !String(first.details.message).includes("started Pi extension arm child")) {
|
|
1614
|
+
throw new Error(`startup arm failed: ${JSON.stringify(first.details)}`);
|
|
1615
|
+
}
|
|
1616
|
+
await waitFor(() => {
|
|
1617
|
+
const arm = currentArm();
|
|
1618
|
+
return arm.pid && arm.marker && existsSync(arm.marker) && pidAlive(arm.pid);
|
|
1619
|
+
}, "startup child");
|
|
1620
|
+
const { pid: startupChild, marker: startupMarker } = currentArm();
|
|
1621
|
+
if (!pidAlive(startupChild)) throw new Error("startup child was not alive");
|
|
1622
|
+
const staleTool = startup.getTool();
|
|
1623
|
+
|
|
1624
|
+
async function replaceSession(previous, reason) {
|
|
1625
|
+
const previousArm = currentArm();
|
|
1626
|
+
await previous.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason }, {});
|
|
1627
|
+
if (previousArm.marker) {
|
|
1628
|
+
await waitFor(() => !existsSync(previousArm.marker), `${reason} previous child exit`);
|
|
1629
|
+
}
|
|
1630
|
+
const next = makePi();
|
|
1631
|
+
mod.default(next.pi);
|
|
1632
|
+
await next.handlers.get("session_start")?.({
|
|
1633
|
+
type: "session_start",
|
|
1634
|
+
reason,
|
|
1635
|
+
previousSessionFile: `/tmp/previous-${reason}.jsonl`,
|
|
1636
|
+
}, {});
|
|
1637
|
+
const armed = await next.getTool().execute(`arm-${reason}`, {}, undefined, undefined, {});
|
|
1638
|
+
if (!armed.details?.ok) {
|
|
1639
|
+
throw new Error(`${reason} replacement arm failed: ${JSON.stringify(armed.details)}`);
|
|
1640
|
+
}
|
|
1641
|
+
if (String(armed.details.message).includes("shutting down")) {
|
|
1642
|
+
throw new Error(`${reason} replacement still refused with shutting-down latch`);
|
|
1643
|
+
}
|
|
1644
|
+
await waitFor(() => {
|
|
1645
|
+
const arm = currentArm();
|
|
1646
|
+
return arm.pid && arm.marker && arm.marker !== previousArm.marker && existsSync(arm.marker) && pidAlive(arm.pid) && liveArmPids().includes(arm.pid);
|
|
1647
|
+
}, `${reason} replacement child and arm record`);
|
|
1648
|
+
const live = liveArmPids();
|
|
1649
|
+
if (live.length !== 1) {
|
|
1650
|
+
throw new Error(`${reason} expected exactly one live arm child, got ${live.join(",") || "(none)"}`);
|
|
1651
|
+
}
|
|
1652
|
+
return next;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
let current = await replaceSession(startup, "new");
|
|
1656
|
+
current = await replaceSession(current, "resume");
|
|
1657
|
+
current = await replaceSession(current, "fork");
|
|
1658
|
+
|
|
1659
|
+
// Same bound instance: ordinary shutdown then session_start without a fresh smidja.
|
|
1660
|
+
const sameInstanceArm = currentArm();
|
|
1661
|
+
await current.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "new" }, {});
|
|
1662
|
+
await current.handlers.get("session_start")?.({ type: "session_start", reason: "new" }, {});
|
|
1663
|
+
const sameInstanceResult = await current.getTool().execute("same-instance", {}, undefined, undefined, {});
|
|
1664
|
+
if (!sameInstanceResult.details?.ok || String(sameInstanceResult.details.message).includes("shutting down")) {
|
|
1665
|
+
throw new Error(`same-instance replacement arm failed: ${JSON.stringify(sameInstanceResult.details)}`);
|
|
1666
|
+
}
|
|
1667
|
+
await waitFor(() => {
|
|
1668
|
+
const arm = currentArm();
|
|
1669
|
+
return arm.pid && arm.marker && arm.marker !== sameInstanceArm.marker && existsSync(arm.marker) && pidAlive(arm.pid) && liveArmPids().includes(arm.pid);
|
|
1670
|
+
}, "same-instance replacement child and arm record");
|
|
1671
|
+
await waitFor(() => !existsSync(sameInstanceArm.marker), "same-instance previous child exit");
|
|
1672
|
+
if (liveArmPids().length !== 1) {
|
|
1673
|
+
throw new Error(`same-instance expected one live arm child, got ${liveArmPids().join(",")}`);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// Stale prior-generation callback must not stop, rearm, or clear the active generation.
|
|
1677
|
+
const activeChild = currentArm().pid;
|
|
1678
|
+
const stale = await staleTool.execute("stale-prior-generation", {}, undefined, undefined, {});
|
|
1679
|
+
if (stale.details?.ok !== false || !String(stale.details.message).includes("shutting down")) {
|
|
1680
|
+
throw new Error(`stale prior generation did not refuse: ${JSON.stringify(stale.details)}`);
|
|
1681
|
+
}
|
|
1682
|
+
if (!pidAlive(activeChild)) throw new Error("active generation child died after stale callback");
|
|
1683
|
+
if (existsSync(startupMarker)) throw new Error("startup generation child was resurrected");
|
|
1684
|
+
// Model the old generation PID being recycled for the active child.
|
|
1685
|
+
// Its retired lifetime marker must keep the historical row from counting live.
|
|
1686
|
+
writeFileSync(process.env.FM_ARM_LOG, `arm pid=${activeChild} marker=${startupMarker}\n`, { flag: "a" });
|
|
1687
|
+
if (liveArmPids().length !== 1 || liveArmPids()[0] !== activeChild) {
|
|
1688
|
+
throw new Error(`stale callback mutated live arm set: ${liveArmPids().join(",")}`);
|
|
1689
|
+
}
|
|
1690
|
+
const redundant = await current.getTool().execute("redundant", {}, undefined, undefined, {});
|
|
1691
|
+
if (!redundant.details?.ok || !String(redundant.details.message).includes("unchanged")) {
|
|
1692
|
+
throw new Error(`active generation lost single-flight ownership: ${JSON.stringify(redundant.details)}`);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// Repeated transitions keep exactly one live cycle and never revive the refusal.
|
|
1696
|
+
for (const reason of ["resume", "fork", "new", "resume"]) {
|
|
1697
|
+
current = await replaceSession(current, reason);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// Real terminal shutdown still blocks late rearming.
|
|
1701
|
+
const finalArm = currentArm();
|
|
1702
|
+
await current.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "quit" }, {});
|
|
1703
|
+
await waitFor(() => !existsSync(finalArm.marker), "terminal shutdown child exit");
|
|
1704
|
+
const quitArm = await current.getTool().execute("after-quit", {}, undefined, undefined, {});
|
|
1705
|
+
if (quitArm.details?.ok !== false || quitArm.details.message !== "watcher: not armed - Pi session is shutting down") {
|
|
1706
|
+
throw new Error(`terminal quit must keep the shutting-down refusal: ${JSON.stringify(quitArm.details)}`);
|
|
1707
|
+
}
|
|
1708
|
+
if (liveArmPids().length !== 0) {
|
|
1709
|
+
throw new Error(`terminal quit left live arm children: ${liveArmPids().join(",")}`);
|
|
1710
|
+
}
|
|
1711
|
+
EOF
|
|
1712
|
+
)
|
|
1713
|
+
status=$?
|
|
1714
|
+
[ "$status" -eq 0 ] || fail "Pi session transitions must rearm through an explicit generation owner (exit $status): $out"
|
|
1715
|
+
[ -z "$out" ] || fail "Pi session-transition generation owner test printed output: $out"
|
|
1716
|
+
pass "Pi session transitions use a generation owner across /new /resume /fork, stale callbacks, and quit"
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
test_pi_process_exit_cleanup_listener_lifecycle() {
|
|
1720
|
+
local repo home plugin out status
|
|
1721
|
+
repo="$TMP_ROOT/pi-exit-listener-root"
|
|
1722
|
+
home="$TMP_ROOT/pi-exit-listener-home"
|
|
1723
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
1724
|
+
install_pi_watch_extension_fixture "$repo"
|
|
1725
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
1726
|
+
: > "$repo/bin/fm-watch-arm.sh"
|
|
1727
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
1728
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" node --input-type=module 2>&1 <<'EOF'
|
|
1729
|
+
import { pathToFileURL } from "node:url";
|
|
1730
|
+
|
|
1731
|
+
const handlers = new Map();
|
|
1732
|
+
const pi = {
|
|
1733
|
+
on(event, handler) {
|
|
1734
|
+
handlers.set(event, handler);
|
|
1735
|
+
},
|
|
1736
|
+
registerCommand() {},
|
|
1737
|
+
registerTool() {},
|
|
1738
|
+
sendUserMessage: async () => {},
|
|
1739
|
+
};
|
|
1740
|
+
const before = process.listenerCount("exit");
|
|
1741
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
1742
|
+
mod.default(pi);
|
|
1743
|
+
if (process.listenerCount("exit") !== before + 1) {
|
|
1744
|
+
throw new Error("Pi extension did not install exactly one process-exit fallback");
|
|
1745
|
+
}
|
|
1746
|
+
await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, {});
|
|
1747
|
+
if (process.listenerCount("exit") !== before + 1) {
|
|
1748
|
+
throw new Error("session_shutdown removed the process-lifetime exit fallback");
|
|
1749
|
+
}
|
|
1750
|
+
await handlers.get("session_start")?.({ type: "session_start" }, {});
|
|
1751
|
+
if (process.listenerCount("exit") !== before + 1) {
|
|
1752
|
+
throw new Error("replacement activation duplicated the process-exit fallback");
|
|
1753
|
+
}
|
|
1754
|
+
EOF
|
|
1755
|
+
)
|
|
1756
|
+
status=$?
|
|
1757
|
+
expect_code 0 "$status" "Pi cleanup fallback listener must remain singular across session replacement"
|
|
1758
|
+
[ -z "$out" ] || fail "Pi listener-lifecycle test printed output: $out"
|
|
1759
|
+
pass "Pi process-exit cleanup listener remains singular across session replacement"
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
test_pi_process_exit_cleanup_stops_arm_child() {
|
|
1763
|
+
local repo home plugin cleanup_log pid_file out status pid i
|
|
1764
|
+
repo="$TMP_ROOT/pi-process-exit-root"
|
|
1765
|
+
home="$TMP_ROOT/pi-process-exit-home"
|
|
1766
|
+
cleanup_log="$TMP_ROOT/pi-process-exit-cleaned"
|
|
1767
|
+
pid_file="$TMP_ROOT/pi-process-exit-child.pid"
|
|
1768
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
1769
|
+
install_pi_watch_extension_fixture "$repo"
|
|
1770
|
+
plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts"
|
|
1771
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
1772
|
+
#!/usr/bin/env bash
|
|
1773
|
+
trap 'printf "%s\n" "$$" >> "$FM_CLEANUP_LOG"; exit 0' TERM
|
|
1774
|
+
printf '%s\n' "$$" > "$FM_CHILD_PID_FILE"
|
|
1775
|
+
while :; do sleep 1; done
|
|
1776
|
+
SH
|
|
1777
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
1778
|
+
out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_CLEANUP_LOG="$cleanup_log" FM_CHILD_PID_FILE="$pid_file" node --input-type=module 2>&1 <<'EOF'
|
|
1779
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
1780
|
+
import { pathToFileURL } from "node:url";
|
|
1781
|
+
|
|
1782
|
+
let tool = null;
|
|
1783
|
+
const handlers = new Map();
|
|
1784
|
+
const pi = {
|
|
1785
|
+
on(event, handler) {
|
|
1786
|
+
handlers.set(event, handler);
|
|
1787
|
+
},
|
|
1788
|
+
registerCommand() {},
|
|
1789
|
+
registerTool(candidate) {
|
|
1790
|
+
if (candidate.name === "fm_watch_arm_pi") tool = candidate;
|
|
1791
|
+
},
|
|
1792
|
+
sendUserMessage: async () => {},
|
|
1793
|
+
};
|
|
1794
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
1795
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
1796
|
+
mod.default(pi);
|
|
1797
|
+
await tool.execute("tool-call-exit", {}, undefined, undefined, {});
|
|
1798
|
+
for (let i = 0; i < 250 && !existsSync(process.env.FM_CHILD_PID_FILE); i += 1) {
|
|
1799
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1800
|
+
}
|
|
1801
|
+
if (!existsSync(process.env.FM_CHILD_PID_FILE)) throw new Error("arm child did not start");
|
|
1802
|
+
const firstChild = readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim();
|
|
1803
|
+
await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, {});
|
|
1804
|
+
await handlers.get("session_start")?.({ type: "session_start" }, {});
|
|
1805
|
+
await tool.execute("tool-call-replacement", {}, undefined, undefined, {});
|
|
1806
|
+
for (let i = 0; i < 250; i += 1) {
|
|
1807
|
+
const currentChild = readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim();
|
|
1808
|
+
if (currentChild !== firstChild) break;
|
|
1809
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1810
|
+
}
|
|
1811
|
+
if (readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim() === firstChild) {
|
|
1812
|
+
throw new Error("replacement arm child did not start");
|
|
1813
|
+
}
|
|
1814
|
+
process.exit(0);
|
|
1815
|
+
EOF
|
|
1816
|
+
)
|
|
1817
|
+
status=$?
|
|
1818
|
+
expect_code 0 "$status" "Pi process exit must run the watcher cleanup fallback"
|
|
1819
|
+
[ -z "$out" ] || fail "Pi process-exit cleanup test printed output: $out"
|
|
1820
|
+
pid=$(cat "$pid_file")
|
|
1821
|
+
i=0
|
|
1822
|
+
while [ "$i" -lt 250 ] && ! grep -qx "$pid" "$cleanup_log" 2>/dev/null; do
|
|
1823
|
+
sleep 0.02
|
|
1824
|
+
i=$((i + 1))
|
|
1825
|
+
done
|
|
1826
|
+
grep -qx "$pid" "$cleanup_log" 2>/dev/null || fail "Pi process-exit fallback did not deliver TERM to the replacement arm child"
|
|
1827
|
+
if kill -0 "$pid" 2>/dev/null; then
|
|
1828
|
+
kill -TERM "$pid" 2>/dev/null || true
|
|
1829
|
+
fail "Pi arm child $pid survived process-exit cleanup"
|
|
1830
|
+
fi
|
|
1831
|
+
pass "Pi process-exit cleanup stops the attached arm child"
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
test_opencode_plugin_package_boundary_is_explicit_esm() {
|
|
1835
|
+
local fixture plugin out status
|
|
1836
|
+
fixture="$TMP_ROOT/opencode-esm-boundary/.opencode"
|
|
1837
|
+
plugin="$fixture/plugins/fm-primary-watch-arm.js"
|
|
1838
|
+
mkdir -p "$fixture/plugins/lib"
|
|
1839
|
+
printf '%s\n' '{"dependencies":{}}' > "$fixture/package.json"
|
|
1840
|
+
cp "$ROOT/.opencode/plugins/package.json" "$fixture/plugins/package.json"
|
|
1841
|
+
cp "$ROOT/.opencode/plugins/fm-primary-watch-arm.js" "$plugin"
|
|
1842
|
+
cp "$ROOT/.opencode/plugins/lib/fm-operational-input.js" "$fixture/plugins/lib/fm-operational-input.js"
|
|
1843
|
+
out=$(PLUGIN="$plugin" node --input-type=module 2>&1 <<'EOF'
|
|
1844
|
+
import { pathToFileURL } from "node:url";
|
|
1845
|
+
await import(pathToFileURL(process.env.PLUGIN).href);
|
|
1846
|
+
EOF
|
|
1847
|
+
)
|
|
1848
|
+
status=$?
|
|
1849
|
+
expect_code 0 "$status" "OpenCode plugin must import beneath an explicit ESM package boundary"
|
|
1850
|
+
[ -z "$out" ] || fail "OpenCode ESM boundary import printed output: $out"
|
|
1851
|
+
pass "OpenCode plugins have an explicit ESM boundary even under a typeless parent package"
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
test_opencode_primary_watch_plugin_uses_effective_state_home() {
|
|
1855
|
+
local plugin repo home log out status
|
|
1856
|
+
plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
|
|
1857
|
+
repo="$TMP_ROOT/opencode-effective-state-root"
|
|
1858
|
+
home="$TMP_ROOT/opencode-effective-state-home"
|
|
1859
|
+
log="$TMP_ROOT/opencode-effective-state.log"
|
|
1860
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
1861
|
+
git init -q "$repo"
|
|
1862
|
+
: > "$repo/AGENTS.md"
|
|
1863
|
+
: > "$home/state/task.meta"
|
|
1864
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
1865
|
+
#!/usr/bin/env bash
|
|
1866
|
+
printf 'home=%s root=%s\n' "${FM_HOME:-}" "${FM_ROOT_OVERRIDE:-}" >> "${FM_ARM_LOG:?}"
|
|
1867
|
+
printf 'watcher: healthy pid=1 (beacon 0s)\n'
|
|
1868
|
+
SH
|
|
1869
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
1870
|
+
out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" node 2>&1 <<'EOF'
|
|
1871
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
1872
|
+
import { pathToFileURL } from "node:url";
|
|
1873
|
+
|
|
1874
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
1875
|
+
const client = { session: { promptAsync: async () => {} } };
|
|
1876
|
+
const hooks = await mod.FmPrimaryWatchArm({
|
|
1877
|
+
client,
|
|
1878
|
+
directory: process.env.WORKTREE,
|
|
1879
|
+
worktree: process.env.WORKTREE,
|
|
1880
|
+
});
|
|
1881
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
1882
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
|
|
1883
|
+
for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) {
|
|
1884
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1885
|
+
}
|
|
1886
|
+
if (!existsSync(process.env.FM_ARM_LOG)) {
|
|
1887
|
+
console.error("watch arm did not run");
|
|
1888
|
+
process.exit(1);
|
|
1889
|
+
}
|
|
1890
|
+
const text = readFileSync(process.env.FM_ARM_LOG, "utf8");
|
|
1891
|
+
const expectedRoot = realpathSync(process.env.WORKTREE);
|
|
1892
|
+
if (!text.includes(`home=${process.env.FM_HOME}`) || !text.includes(`root=${expectedRoot}`)) {
|
|
1893
|
+
console.error(text);
|
|
1894
|
+
process.exit(1);
|
|
1895
|
+
}
|
|
1896
|
+
EOF
|
|
1897
|
+
)
|
|
1898
|
+
status=$?
|
|
1899
|
+
expect_code 0 "$status" "OpenCode watch plugin must use FM_HOME state outside the repo root"
|
|
1900
|
+
[ -z "$out" ] || fail "OpenCode effective-state test printed output: $out"
|
|
1901
|
+
pass "OpenCode watcher plugin uses the effective FM_HOME state"
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
test_opencode_primary_watch_plugin_sources_effective_config() {
|
|
1905
|
+
local plugin repo home log out status
|
|
1906
|
+
plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
|
|
1907
|
+
repo="$TMP_ROOT/opencode-effective-config-root"
|
|
1908
|
+
home="$TMP_ROOT/opencode-effective-config-home"
|
|
1909
|
+
log="$TMP_ROOT/opencode-effective-config.log"
|
|
1910
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
1911
|
+
git init -q "$repo"
|
|
1912
|
+
: > "$repo/AGENTS.md"
|
|
1913
|
+
printf 'export FM_POLL=7\n' > "$home/config/x-mode.env"
|
|
1914
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
1915
|
+
#!/usr/bin/env bash
|
|
1916
|
+
printf 'poll=%s\n' "${FM_POLL:-missing}" >> "${FM_ARM_LOG:?}"
|
|
1917
|
+
printf 'watcher: healthy pid=1 (beacon 0s)\n'
|
|
1918
|
+
SH
|
|
1919
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
1920
|
+
out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" node 2>&1 <<'EOF'
|
|
1921
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
1922
|
+
import { pathToFileURL } from "node:url";
|
|
1923
|
+
|
|
1924
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
1925
|
+
const client = { session: { promptAsync: async () => {} } };
|
|
1926
|
+
const hooks = await mod.FmPrimaryWatchArm({
|
|
1927
|
+
client,
|
|
1928
|
+
directory: process.env.WORKTREE,
|
|
1929
|
+
worktree: process.env.WORKTREE,
|
|
1930
|
+
});
|
|
1931
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
1932
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
|
|
1933
|
+
for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) {
|
|
1934
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1935
|
+
}
|
|
1936
|
+
if (!existsSync(process.env.FM_ARM_LOG)) {
|
|
1937
|
+
console.error("watch arm did not run");
|
|
1938
|
+
process.exit(1);
|
|
1939
|
+
}
|
|
1940
|
+
const text = readFileSync(process.env.FM_ARM_LOG, "utf8");
|
|
1941
|
+
if (!text.includes("poll=7")) {
|
|
1942
|
+
console.error(text);
|
|
1943
|
+
process.exit(1);
|
|
1944
|
+
}
|
|
1945
|
+
EOF
|
|
1946
|
+
)
|
|
1947
|
+
status=$?
|
|
1948
|
+
expect_code 0 "$status" "OpenCode watch plugin must source FM_HOME config outside the repo root"
|
|
1949
|
+
[ -z "$out" ] || fail "OpenCode effective-config test printed output: $out"
|
|
1950
|
+
pass "OpenCode watcher plugin sources the effective config"
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
test_opencode_primary_watch_plugin_requires_session_lock() {
|
|
1954
|
+
local plugin repo home log out status
|
|
1955
|
+
plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
|
|
1956
|
+
repo="$TMP_ROOT/opencode-lock-root"
|
|
1957
|
+
home="$TMP_ROOT/opencode-lock-home"
|
|
1958
|
+
log="$TMP_ROOT/opencode-lock.log"
|
|
1959
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
1960
|
+
git init -q "$repo"
|
|
1961
|
+
: > "$repo/AGENTS.md"
|
|
1962
|
+
: > "$home/state/task.meta"
|
|
1963
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
1964
|
+
#!/usr/bin/env bash
|
|
1965
|
+
printf 'arm\n' >> "${FM_ARM_LOG:?}"
|
|
1966
|
+
printf 'watcher: healthy pid=1 (beacon 0s)\n'
|
|
1967
|
+
SH
|
|
1968
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
1969
|
+
out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" node 2>&1 <<'EOF'
|
|
1970
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
1971
|
+
import { pathToFileURL } from "node:url";
|
|
1972
|
+
|
|
1973
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
1974
|
+
const client = { session: { promptAsync: async () => {} } };
|
|
1975
|
+
const hooks = await mod.FmPrimaryWatchArm({
|
|
1976
|
+
client,
|
|
1977
|
+
directory: process.env.WORKTREE,
|
|
1978
|
+
worktree: process.env.WORKTREE,
|
|
1979
|
+
});
|
|
1980
|
+
const event = { event: { type: "session.idle", properties: { sessionID: "session-test" } } };
|
|
1981
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, "999999\n");
|
|
1982
|
+
await hooks.event(event);
|
|
1983
|
+
// The hook starts its attempt without awaiting it, and the plugin answers a
|
|
1984
|
+
// second attempt from the one already in flight. Join that attempt through the
|
|
1985
|
+
// coordinator rather than waiting a fixed span: refusing an unowned lock walks
|
|
1986
|
+
// git and ps probes that can outlast any such span, and the owned-lock event
|
|
1987
|
+
// below would then be answered from the refusal instead of arming.
|
|
1988
|
+
const refusal = await globalThis.__firstmateOpenCodeWatchArm.ensureArmed("session-test", client);
|
|
1989
|
+
if (refusal !== "read-only") {
|
|
1990
|
+
console.error(`expected a read-only refusal without the session lock, got ${refusal}`);
|
|
1991
|
+
process.exit(1);
|
|
1992
|
+
}
|
|
1993
|
+
if (existsSync(process.env.FM_ARM_LOG)) {
|
|
1994
|
+
console.error("watch arm ran without owning the session lock");
|
|
1995
|
+
process.exit(1);
|
|
1996
|
+
}
|
|
1997
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
1998
|
+
await hooks.event(event);
|
|
1999
|
+
for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) {
|
|
2000
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
2001
|
+
}
|
|
2002
|
+
if (!existsSync(process.env.FM_ARM_LOG)) {
|
|
2003
|
+
console.error("watch arm did not run after the session lock matched");
|
|
2004
|
+
process.exit(1);
|
|
2005
|
+
}
|
|
2006
|
+
EOF
|
|
2007
|
+
)
|
|
2008
|
+
status=$?
|
|
2009
|
+
expect_code 0 "$status" "OpenCode watch plugin must arm only when this session owns the fleet lock"
|
|
2010
|
+
[ -z "$out" ] || fail "OpenCode session-lock test printed output: $out"
|
|
2011
|
+
pass "OpenCode watcher plugin requires session lock ownership"
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
test_opencode_watch_arm_coordinator_respects_primary_scope() {
|
|
2015
|
+
local plugin base repo home log out status
|
|
2016
|
+
plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
|
|
2017
|
+
base="$TMP_ROOT/opencode-coordinator-base"
|
|
2018
|
+
repo="$TMP_ROOT/opencode-coordinator-wt"
|
|
2019
|
+
home="$TMP_ROOT/opencode-coordinator-home"
|
|
2020
|
+
log="$TMP_ROOT/opencode-coordinator.log"
|
|
2021
|
+
fm_git_worktree "$base" "$repo" fm/opencode-coordinator
|
|
2022
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
2023
|
+
: > "$repo/AGENTS.md"
|
|
2024
|
+
: > "$home/state/task.meta"
|
|
2025
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
2026
|
+
#!/usr/bin/env bash
|
|
2027
|
+
printf 'arm\n' >> "${FM_ARM_LOG:?}"
|
|
2028
|
+
printf 'watcher: healthy pid=1 (beacon 0s)\n'
|
|
2029
|
+
SH
|
|
2030
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
2031
|
+
out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" node 2>&1 <<'EOF'
|
|
2032
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
2033
|
+
import { pathToFileURL } from "node:url";
|
|
2034
|
+
|
|
2035
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
2036
|
+
const client = { session: { promptAsync: async () => {} } };
|
|
2037
|
+
await mod.FmPrimaryWatchArm({
|
|
2038
|
+
client,
|
|
2039
|
+
directory: process.env.WORKTREE,
|
|
2040
|
+
worktree: process.env.WORKTREE,
|
|
2041
|
+
});
|
|
2042
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
2043
|
+
const status = await globalThis.__firstmateOpenCodeWatchArm.ensureArmed("session-test", client);
|
|
2044
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
2045
|
+
if (status !== "not-primary") {
|
|
2046
|
+
console.error(`expected not-primary, got ${status}`);
|
|
2047
|
+
process.exit(1);
|
|
2048
|
+
}
|
|
2049
|
+
if (existsSync(process.env.FM_ARM_LOG)) {
|
|
2050
|
+
console.error("coordinator armed from a linked worktree");
|
|
2051
|
+
process.exit(1);
|
|
2052
|
+
}
|
|
2053
|
+
EOF
|
|
2054
|
+
)
|
|
2055
|
+
status=$?
|
|
2056
|
+
expect_code 0 "$status" "OpenCode watch coordinator must keep primary scope checks in the shared arm path"
|
|
2057
|
+
[ -z "$out" ] || fail "OpenCode coordinator-scope test printed output: $out"
|
|
2058
|
+
pass "OpenCode watcher coordinator respects primary scope"
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
test_opencode_primary_watch_plugin_rearms_after_wake() {
|
|
2062
|
+
local plugin repo home log stop out status
|
|
2063
|
+
plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
|
|
2064
|
+
repo="$TMP_ROOT/opencode-rearm-root"
|
|
2065
|
+
home="$TMP_ROOT/opencode-rearm-home"
|
|
2066
|
+
log="$TMP_ROOT/opencode-rearm.log"
|
|
2067
|
+
stop="$TMP_ROOT/opencode-rearm.stop"
|
|
2068
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
2069
|
+
git init -q "$repo"
|
|
2070
|
+
: > "$repo/AGENTS.md"
|
|
2071
|
+
: > "$home/state/task.meta"
|
|
2072
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
2073
|
+
#!/usr/bin/env bash
|
|
2074
|
+
if [ "${1:-}" = --handling-delivered ]; then
|
|
2075
|
+
printf 'confirmed generation=%s watcher=%s\n' "$2" "$4" >> "${FM_ARM_LOG:?}"
|
|
2076
|
+
exit 0
|
|
2077
|
+
fi
|
|
2078
|
+
printf 'arm=%s predecessor=%s\n' "$$" "${FM_WATCH_PREDECESSOR_ARM_PID:-none}" >> "${FM_ARM_LOG:?}"
|
|
2079
|
+
count=$(grep -c '^arm=' "$FM_ARM_LOG")
|
|
2080
|
+
if [ "$count" -eq 1 ]; then
|
|
2081
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
2082
|
+
printf 'signal: synthetic wake\n'
|
|
2083
|
+
exit 0
|
|
2084
|
+
fi
|
|
2085
|
+
printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$"
|
|
2086
|
+
trap 'exit 0' TERM INT
|
|
2087
|
+
while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
|
|
2088
|
+
SH
|
|
2089
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
2090
|
+
out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node 2>&1 <<'EOF'
|
|
2091
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2092
|
+
import { pathToFileURL } from "node:url";
|
|
2093
|
+
|
|
2094
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
2095
|
+
let prompts = 0;
|
|
2096
|
+
let rowsAtPrompt = 0;
|
|
2097
|
+
let releasePrompt = () => {};
|
|
2098
|
+
const promptBlocked = new Promise((resolve) => {
|
|
2099
|
+
releasePrompt = resolve;
|
|
2100
|
+
});
|
|
2101
|
+
const client = {
|
|
2102
|
+
session: {
|
|
2103
|
+
promptAsync: async () => {
|
|
2104
|
+
rowsAtPrompt = existsSync(process.env.FM_ARM_LOG)
|
|
2105
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n").filter((row) => row.startsWith("arm=")).length
|
|
2106
|
+
: 0;
|
|
2107
|
+
prompts += 1;
|
|
2108
|
+
await promptBlocked;
|
|
2109
|
+
},
|
|
2110
|
+
},
|
|
2111
|
+
};
|
|
2112
|
+
const hooks = await mod.FmPrimaryWatchArm({
|
|
2113
|
+
client,
|
|
2114
|
+
directory: process.env.WORKTREE,
|
|
2115
|
+
worktree: process.env.WORKTREE,
|
|
2116
|
+
});
|
|
2117
|
+
const event = { event: { type: "session.idle", properties: { sessionID: "session-test" } } };
|
|
2118
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
2119
|
+
await hooks.event(event);
|
|
2120
|
+
for (let i = 0; i < 250; i += 1) {
|
|
2121
|
+
const rows = existsSync(process.env.FM_ARM_LOG)
|
|
2122
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
|
|
2123
|
+
: [];
|
|
2124
|
+
if (rows.length >= 2 && prompts >= 1) break;
|
|
2125
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2126
|
+
}
|
|
2127
|
+
const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
2128
|
+
const armRows = rows.filter((row) => row.startsWith("arm="));
|
|
2129
|
+
if (armRows.length !== 2) throw new Error(`expected one successor arm, got ${armRows.length}: ${rows.join(" | ")}`);
|
|
2130
|
+
if (prompts !== 1) throw new Error(`expected one blocked wake prompt, got ${prompts}`);
|
|
2131
|
+
if (rowsAtPrompt !== 2) throw new Error(`wake prompt began before successor establishment (${rowsAtPrompt} arm rows)`);
|
|
2132
|
+
if (!/predecessor=[0-9]+/.test(armRows[1])) throw new Error(`successor did not receive predecessor identity: ${armRows[1]}`);
|
|
2133
|
+
if (!rows.some((row) => row.startsWith("confirmed generation=fixture-generation"))) {
|
|
2134
|
+
throw new Error(`handling delivery was not confirmed before the follow-up: ${rows.join(" | ")}`);
|
|
2135
|
+
}
|
|
2136
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2137
|
+
const stableRows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
2138
|
+
if (stableRows.filter((row) => row.startsWith("arm=")).length !== 2) {
|
|
2139
|
+
throw new Error(`blocked follow-up started extra arm work: ${stableRows.join(" | ")}`);
|
|
2140
|
+
}
|
|
2141
|
+
if (stableRows.filter((row) => row.startsWith("confirmed ")).length !== 1) {
|
|
2142
|
+
throw new Error(`successful prompt delivery was not confirmed exactly once: ${stableRows.join(" | ")}`);
|
|
2143
|
+
}
|
|
2144
|
+
releasePrompt();
|
|
2145
|
+
writeFileSync(process.env.FM_STOP_FILE, "stop\n");
|
|
2146
|
+
EOF
|
|
2147
|
+
)
|
|
2148
|
+
status=$?
|
|
2149
|
+
[ "$status" -eq 0 ] || fail "OpenCode watch plugin must start one successor before wake prompt delivery settles: $out"
|
|
2150
|
+
[ -z "$out" ] || fail "OpenCode rearm test printed output: $out"
|
|
2151
|
+
pass "OpenCode watcher plugin starts one successor before wake prompt delivery settles"
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
test_opencode_pre_ready_actionable_close_preserves_its_successor() {
|
|
2155
|
+
local plugin repo home log release retired stop out status
|
|
2156
|
+
plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
|
|
2157
|
+
repo="$TMP_ROOT/opencode-pre-ready-actionable-root"
|
|
2158
|
+
home="$TMP_ROOT/opencode-pre-ready-actionable-home"
|
|
2159
|
+
log="$TMP_ROOT/opencode-pre-ready-actionable.log"
|
|
2160
|
+
release="$TMP_ROOT/opencode-pre-ready-actionable.release"
|
|
2161
|
+
retired="$TMP_ROOT/opencode-pre-ready-actionable.retired"
|
|
2162
|
+
stop="$TMP_ROOT/opencode-pre-ready-actionable.stop"
|
|
2163
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
2164
|
+
git init -q "$repo"
|
|
2165
|
+
: > "$repo/AGENTS.md"
|
|
2166
|
+
: > "$home/state/task.meta"
|
|
2167
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
2168
|
+
#!/usr/bin/env bash
|
|
2169
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
2170
|
+
count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
|
|
2171
|
+
if [ "$count" -eq 1 ]; then
|
|
2172
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
2173
|
+
printf 'signal: original wake\n'
|
|
2174
|
+
exit 0
|
|
2175
|
+
fi
|
|
2176
|
+
if [ "$count" -eq 2 ]; then
|
|
2177
|
+
printf 'signal: pre-ready successor wake\n'
|
|
2178
|
+
trap 'printf "retired\\n" > "${FM_PRE_READY_RETIRED_FILE:?}"; exit 0' TERM INT
|
|
2179
|
+
while [ ! -e "$FM_PRE_READY_RELEASE_FILE" ]; do sleep 0.02; done
|
|
2180
|
+
exit 0
|
|
2181
|
+
fi
|
|
2182
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
2183
|
+
trap 'exit 0' TERM INT
|
|
2184
|
+
while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
|
|
2185
|
+
SH
|
|
2186
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
2187
|
+
out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_PRE_READY_RELEASE_FILE="$release" FM_PRE_READY_RETIRED_FILE="$retired" FM_STOP_FILE="$stop" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node 2>&1 <<'EOF'
|
|
2188
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2189
|
+
import { pathToFileURL } from "node:url";
|
|
2190
|
+
|
|
2191
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
2192
|
+
const prompts = [];
|
|
2193
|
+
const client = {
|
|
2194
|
+
session: {
|
|
2195
|
+
promptAsync: async (request) => {
|
|
2196
|
+
prompts.push(request.body.parts[0].text);
|
|
2197
|
+
},
|
|
2198
|
+
},
|
|
2199
|
+
};
|
|
2200
|
+
const hooks = await mod.FmPrimaryWatchArm({
|
|
2201
|
+
client,
|
|
2202
|
+
directory: process.env.WORKTREE,
|
|
2203
|
+
worktree: process.env.WORKTREE,
|
|
2204
|
+
});
|
|
2205
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
2206
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
|
|
2207
|
+
for (let i = 0; i < 500; i += 1) {
|
|
2208
|
+
const rows = existsSync(process.env.FM_ARM_LOG)
|
|
2209
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
|
|
2210
|
+
: [];
|
|
2211
|
+
if (rows.length >= 2 && prompts.some((message) => message.includes("original wake"))) break;
|
|
2212
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2213
|
+
}
|
|
2214
|
+
const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
2215
|
+
if (rows.length !== 2) throw new Error(`pre-ready successor was replaced before its close: ${rows.join(" | ")}`);
|
|
2216
|
+
if (!prompts.some((message) => message.includes("original wake"))) throw new Error(`original actionable wake was not delivered: ${prompts.join(" | ")}`);
|
|
2217
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
2218
|
+
if (existsSync(process.env.FM_PRE_READY_RETIRED_FILE)) throw new Error("pre-ready actionable successor was retired before its close");
|
|
2219
|
+
writeFileSync(process.env.FM_PRE_READY_RELEASE_FILE, "release\n");
|
|
2220
|
+
for (let i = 0; i < 500; i += 1) {
|
|
2221
|
+
const successorRows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
2222
|
+
if (successorRows.length >= 3 && prompts.some((message) => message.includes("pre-ready successor wake"))) break;
|
|
2223
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2224
|
+
}
|
|
2225
|
+
const stableRows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
2226
|
+
if (stableRows.length !== 3) throw new Error(`pre-ready close did not create exactly one successor: ${stableRows.join(" | ")}`);
|
|
2227
|
+
if (!prompts.some((message) => message.includes("pre-ready successor wake"))) throw new Error(`pre-ready actionable wake was not delivered: ${prompts.join(" | ")}`);
|
|
2228
|
+
writeFileSync(process.env.FM_STOP_FILE, "stop\n");
|
|
2229
|
+
EOF
|
|
2230
|
+
)
|
|
2231
|
+
status=$?
|
|
2232
|
+
expect_code 0 "$status" "OpenCode must retire the pre-ready arm, not its actionable successor"
|
|
2233
|
+
[ -z "$out" ] || fail "OpenCode pre-ready actionable test printed output: $out"
|
|
2234
|
+
pass "OpenCode pre-ready actionable close preserves its successor"
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
test_opencode_hung_successor_falls_back_to_typed_wake() {
|
|
2238
|
+
local plugin repo home log out status
|
|
2239
|
+
plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
|
|
2240
|
+
repo="$TMP_ROOT/opencode-hung-successor-root"
|
|
2241
|
+
home="$TMP_ROOT/opencode-hung-successor-home"
|
|
2242
|
+
log="$TMP_ROOT/opencode-hung-successor.log"
|
|
2243
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
2244
|
+
git init -q "$repo"
|
|
2245
|
+
: > "$repo/AGENTS.md"
|
|
2246
|
+
: > "$home/state/task.meta"
|
|
2247
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
2248
|
+
#!/usr/bin/env bash
|
|
2249
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
2250
|
+
count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
|
|
2251
|
+
if [ "$count" -eq 1 ]; then
|
|
2252
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
2253
|
+
printf 'signal: synthetic wake\n'
|
|
2254
|
+
exit 0
|
|
2255
|
+
fi
|
|
2256
|
+
trap 'exit 0' TERM INT
|
|
2257
|
+
while :; do sleep 0.02; done
|
|
2258
|
+
SH
|
|
2259
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
2260
|
+
out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_OPENCODE_ARM_READY_TIMEOUT_MS="$ARM_READY_TIMEOUT_MS" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node 2>&1 <<'EOF'
|
|
2261
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2262
|
+
import { pathToFileURL } from "node:url";
|
|
2263
|
+
|
|
2264
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
2265
|
+
let prompt = "";
|
|
2266
|
+
let rowsAtPrompt = 0;
|
|
2267
|
+
const client = {
|
|
2268
|
+
session: {
|
|
2269
|
+
promptAsync: async (request) => {
|
|
2270
|
+
prompt += request.body.parts[0].text;
|
|
2271
|
+
rowsAtPrompt = existsSync(process.env.FM_ARM_LOG)
|
|
2272
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n").length
|
|
2273
|
+
: 0;
|
|
2274
|
+
},
|
|
2275
|
+
},
|
|
2276
|
+
};
|
|
2277
|
+
const hooks = await mod.FmPrimaryWatchArm({
|
|
2278
|
+
client,
|
|
2279
|
+
directory: process.env.WORKTREE,
|
|
2280
|
+
worktree: process.env.WORKTREE,
|
|
2281
|
+
});
|
|
2282
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
2283
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
|
|
2284
|
+
// Three unready successors each cost the full readiness budget, so wait well
|
|
2285
|
+
// past their sum. The wait ends as soon as the wake lands.
|
|
2286
|
+
for (let i = 0; i < 1500 && !prompt; i += 1) {
|
|
2287
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2288
|
+
}
|
|
2289
|
+
const rows = existsSync(process.env.FM_ARM_LOG)
|
|
2290
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
|
|
2291
|
+
: [];
|
|
2292
|
+
if (rows.length !== 4) throw new Error(`expected one successor plus two retries, got ${rows.length}: ${rows.join(" | ")}`);
|
|
2293
|
+
if (rowsAtPrompt !== 4) throw new Error(`wake arrived before restoration exhausted (${rowsAtPrompt} arm rows)`);
|
|
2294
|
+
if (!prompt.includes("signal: synthetic wake")) throw new Error(`original wake was lost: ${prompt}`);
|
|
2295
|
+
if (!prompt.includes("could not restore watcher continuity after 2 retries")) throw new Error(`missing typed restoration failure: ${prompt}`);
|
|
2296
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2297
|
+
const stableRows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
2298
|
+
if (stableRows.length !== 4) throw new Error(`single-flight recovery launched ${stableRows.length} arms`);
|
|
2299
|
+
EOF
|
|
2300
|
+
)
|
|
2301
|
+
status=$?
|
|
2302
|
+
expect_code 0 "$status" "OpenCode must deliver the actionable wake after bounded hung-successor recovery"
|
|
2303
|
+
[ -z "$out" ] || fail "OpenCode hung-successor test printed output: $out"
|
|
2304
|
+
pass "OpenCode hung successor falls back to one typed actionable wake"
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
test_opencode_unretired_successor_falls_back_without_retry() {
|
|
2308
|
+
local plugin repo home log release out status
|
|
2309
|
+
plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
|
|
2310
|
+
repo="$TMP_ROOT/opencode-unretired-successor-root"
|
|
2311
|
+
home="$TMP_ROOT/opencode-unretired-successor-home"
|
|
2312
|
+
log="$TMP_ROOT/opencode-unretired-successor.log"
|
|
2313
|
+
release="$TMP_ROOT/opencode-unretired-successor.release"
|
|
2314
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
2315
|
+
git init -q "$repo"
|
|
2316
|
+
: > "$repo/AGENTS.md"
|
|
2317
|
+
: > "$home/state/task.meta"
|
|
2318
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
2319
|
+
#!/usr/bin/env bash
|
|
2320
|
+
if [ -f "$FM_ARM_LOG" ]; then
|
|
2321
|
+
count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
|
|
2322
|
+
else
|
|
2323
|
+
count=0
|
|
2324
|
+
fi
|
|
2325
|
+
if [ "$count" -eq 0 ]; then
|
|
2326
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
2327
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
2328
|
+
printf 'signal: synthetic wake\n'
|
|
2329
|
+
exit 0
|
|
2330
|
+
fi
|
|
2331
|
+
trap '' TERM INT
|
|
2332
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
2333
|
+
while [ ! -e "$FM_RELEASE_FILE" ]; do sleep 0.1; done
|
|
2334
|
+
SH
|
|
2335
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
2336
|
+
out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_RELEASE_FILE="$release" FM_OPENCODE_ARM_READY_TIMEOUT_MS="$ARM_READY_TIMEOUT_MS" FM_WATCH_ARM_RETIRE_TIMEOUT_MS=20 FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node 2>&1 <<'EOF'
|
|
2337
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2338
|
+
import { pathToFileURL } from "node:url";
|
|
2339
|
+
|
|
2340
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
2341
|
+
let prompt = "";
|
|
2342
|
+
let rowsAtPrompt = 0;
|
|
2343
|
+
const client = {
|
|
2344
|
+
session: {
|
|
2345
|
+
promptAsync: async (request) => {
|
|
2346
|
+
prompt += request.body.parts[0].text;
|
|
2347
|
+
rowsAtPrompt = existsSync(process.env.FM_ARM_LOG)
|
|
2348
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n").length
|
|
2349
|
+
: 0;
|
|
2350
|
+
},
|
|
2351
|
+
},
|
|
2352
|
+
};
|
|
2353
|
+
const hooks = await mod.FmPrimaryWatchArm({
|
|
2354
|
+
client,
|
|
2355
|
+
directory: process.env.WORKTREE,
|
|
2356
|
+
worktree: process.env.WORKTREE,
|
|
2357
|
+
});
|
|
2358
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
2359
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
|
|
2360
|
+
for (let i = 0; i < 500 && !prompt; i += 1) {
|
|
2361
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2362
|
+
}
|
|
2363
|
+
const rows = existsSync(process.env.FM_ARM_LOG)
|
|
2364
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
|
|
2365
|
+
: [];
|
|
2366
|
+
if (rows.length !== 2) throw new Error(`unretired arm overlapped a retry: ${rows.join(" | ")}`);
|
|
2367
|
+
if (rowsAtPrompt !== 2) throw new Error(`wake arrived after an overlapping retry (${rowsAtPrompt} arm rows)`);
|
|
2368
|
+
if (!prompt.includes("signal: synthetic wake")) throw new Error(`original wake was lost: ${prompt}`);
|
|
2369
|
+
if (!prompt.includes("unready successor arm did not exit within 20ms")) throw new Error(`missing unretired-arm failure: ${prompt}`);
|
|
2370
|
+
writeFileSync(process.env.FM_RELEASE_FILE, "release\n");
|
|
2371
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
2372
|
+
EOF
|
|
2373
|
+
)
|
|
2374
|
+
status=$?
|
|
2375
|
+
expect_code 0 "$status" "OpenCode must fall back without overlapping an unretired successor"
|
|
2376
|
+
[ -z "$out" ] || fail "OpenCode unretired-successor test printed output: $out"
|
|
2377
|
+
pass "OpenCode unretired successor falls back without an overlapping retry"
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
test_opencode_late_unretired_close_resumes_supervision() {
|
|
2381
|
+
local kind plugin repo home log ready retired release stop out status
|
|
2382
|
+
for kind in actionable non-actionable; do
|
|
2383
|
+
plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
|
|
2384
|
+
repo="$TMP_ROOT/opencode-late-$kind-root"
|
|
2385
|
+
home="$TMP_ROOT/opencode-late-$kind-home"
|
|
2386
|
+
log="$TMP_ROOT/opencode-late-$kind.log"
|
|
2387
|
+
ready="$TMP_ROOT/opencode-late-$kind.ready"
|
|
2388
|
+
retired="$TMP_ROOT/opencode-late-$kind.retired"
|
|
2389
|
+
release="$TMP_ROOT/opencode-late-$kind.release"
|
|
2390
|
+
stop="$TMP_ROOT/opencode-late-$kind.stop"
|
|
2391
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
2392
|
+
git init -q "$repo"
|
|
2393
|
+
: > "$repo/AGENTS.md"
|
|
2394
|
+
: > "$home/state/task.meta"
|
|
2395
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
2396
|
+
#!/usr/bin/env bash
|
|
2397
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
2398
|
+
count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
|
|
2399
|
+
if [ "$count" -eq 1 ]; then
|
|
2400
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
2401
|
+
printf 'signal: original wake\n'
|
|
2402
|
+
exit 0
|
|
2403
|
+
fi
|
|
2404
|
+
if [ "$count" -eq 2 ]; then
|
|
2405
|
+
trap 'printf "retired\\n" > "${FM_UNRETIRED_RETIRE_FILE:?}"' TERM INT
|
|
2406
|
+
printf 'ready\n' > "${FM_UNRETIRED_READY_FILE:?}"
|
|
2407
|
+
while [ ! -e "$FM_RELEASE_FILE" ]; do sleep 0.02; done
|
|
2408
|
+
[ "$FM_LATE_KIND" = actionable ] && printf 'signal: late wake\n'
|
|
2409
|
+
exit 0
|
|
2410
|
+
fi
|
|
2411
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
2412
|
+
trap 'exit 0' TERM INT
|
|
2413
|
+
while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
|
|
2414
|
+
SH
|
|
2415
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
2416
|
+
out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_UNRETIRED_READY_FILE="$ready" FM_UNRETIRED_RETIRE_FILE="$retired" FM_RELEASE_FILE="$release" FM_STOP_FILE="$stop" FM_LATE_KIND="$kind" FM_OPENCODE_ARM_READY_TIMEOUT_MS="$ARM_READY_TIMEOUT_MS" FM_WATCH_ARM_RETIRE_TIMEOUT_MS=20 FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node 2>&1 <<'EOF'
|
|
2417
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2418
|
+
import { pathToFileURL } from "node:url";
|
|
2419
|
+
|
|
2420
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
2421
|
+
const prompts = [];
|
|
2422
|
+
const client = {
|
|
2423
|
+
session: {
|
|
2424
|
+
promptAsync: async (request) => {
|
|
2425
|
+
prompts.push(request.body.parts[0].text);
|
|
2426
|
+
},
|
|
2427
|
+
},
|
|
2428
|
+
};
|
|
2429
|
+
const rows = () => existsSync(process.env.FM_ARM_LOG)
|
|
2430
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
|
|
2431
|
+
: [];
|
|
2432
|
+
async function waitFor(predicate, message) {
|
|
2433
|
+
for (let i = 0; i < 500; i += 1) {
|
|
2434
|
+
if (predicate()) return;
|
|
2435
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2436
|
+
}
|
|
2437
|
+
throw new Error(message);
|
|
2438
|
+
}
|
|
2439
|
+
const hooks = await mod.FmPrimaryWatchArm({
|
|
2440
|
+
client,
|
|
2441
|
+
directory: process.env.WORKTREE,
|
|
2442
|
+
worktree: process.env.WORKTREE,
|
|
2443
|
+
});
|
|
2444
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
2445
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
|
|
2446
|
+
await waitFor(
|
|
2447
|
+
() => existsSync(process.env.FM_UNRETIRED_READY_FILE),
|
|
2448
|
+
"unretired successor did not enter its retirement wait",
|
|
2449
|
+
);
|
|
2450
|
+
await waitFor(() => prompts.length >= 1, "original fallback was not delivered");
|
|
2451
|
+
await waitFor(
|
|
2452
|
+
() => existsSync(process.env.FM_UNRETIRED_RETIRE_FILE),
|
|
2453
|
+
"unretired successor was not asked to retire before fallback",
|
|
2454
|
+
);
|
|
2455
|
+
if (rows().length !== 2) throw new Error(`unretired arm overlapped before fallback: ${rows().join(" | ")}`);
|
|
2456
|
+
if (!prompts[0]?.includes("original wake")) throw new Error(`missing original fallback: ${prompts.join(" | ")}`);
|
|
2457
|
+
writeFileSync(process.env.FM_RELEASE_FILE, "release\n");
|
|
2458
|
+
for (let i = 0; i < 500; i += 1) {
|
|
2459
|
+
if (rows().length >= 3 && (process.env.FM_LATE_KIND !== "actionable" || prompts.some((message) => message.includes("late wake")))) break;
|
|
2460
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2461
|
+
}
|
|
2462
|
+
if (rows().length !== 3) throw new Error(`late close did not restore one successor: ${rows().join(" | ")}`);
|
|
2463
|
+
if (process.env.FM_LATE_KIND === "actionable") {
|
|
2464
|
+
if (prompts.length !== 2 || !prompts[1].includes("late wake")) throw new Error(`late actionable close was not delivered: ${prompts.join(" | ")}`);
|
|
2465
|
+
} else if (prompts.length !== 1) {
|
|
2466
|
+
throw new Error(`late non-actionable close sent an extra wake: ${prompts.join(" | ")}`);
|
|
2467
|
+
}
|
|
2468
|
+
writeFileSync(process.env.FM_STOP_FILE, "stop\n");
|
|
2469
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
2470
|
+
EOF
|
|
2471
|
+
)
|
|
2472
|
+
status=$?
|
|
2473
|
+
expect_code 0 "$status" "OpenCode late $kind close must remain supervised after fallback"
|
|
2474
|
+
[ -z "$out" ] || fail "OpenCode late-$kind test printed output: $out"
|
|
2475
|
+
done
|
|
2476
|
+
pass "OpenCode late unretired closes resume classified supervision"
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
test_opencode_empty_close_retries_instead_of_disappearing() {
|
|
2480
|
+
local plugin repo home log stop out status
|
|
2481
|
+
plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
|
|
2482
|
+
repo="$TMP_ROOT/opencode-empty-close-root"
|
|
2483
|
+
home="$TMP_ROOT/opencode-empty-close-home"
|
|
2484
|
+
log="$TMP_ROOT/opencode-empty-close.log"
|
|
2485
|
+
stop="$TMP_ROOT/opencode-empty-close.stop"
|
|
2486
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
2487
|
+
git init -q "$repo"
|
|
2488
|
+
: > "$repo/AGENTS.md"
|
|
2489
|
+
: > "$home/state/task.meta"
|
|
2490
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
2491
|
+
#!/usr/bin/env bash
|
|
2492
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
2493
|
+
count=$(wc -l < "$FM_ARM_LOG" | tr -d '[:space:]')
|
|
2494
|
+
if [ "$count" -eq 1 ]; then exit 0; fi
|
|
2495
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
2496
|
+
trap 'exit 0' TERM INT
|
|
2497
|
+
while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done
|
|
2498
|
+
SH
|
|
2499
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
2500
|
+
out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node 2>&1 <<'EOF'
|
|
2501
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2502
|
+
import { pathToFileURL } from "node:url";
|
|
2503
|
+
|
|
2504
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
2505
|
+
let prompts = 0;
|
|
2506
|
+
const client = {
|
|
2507
|
+
session: {
|
|
2508
|
+
promptAsync: async () => {
|
|
2509
|
+
prompts += 1;
|
|
2510
|
+
},
|
|
2511
|
+
},
|
|
2512
|
+
};
|
|
2513
|
+
const hooks = await mod.FmPrimaryWatchArm({
|
|
2514
|
+
client,
|
|
2515
|
+
directory: process.env.WORKTREE,
|
|
2516
|
+
worktree: process.env.WORKTREE,
|
|
2517
|
+
});
|
|
2518
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
2519
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
|
|
2520
|
+
for (let i = 0; i < 250; i += 1) {
|
|
2521
|
+
const rows = existsSync(process.env.FM_ARM_LOG)
|
|
2522
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
|
|
2523
|
+
: [];
|
|
2524
|
+
if (rows.length >= 2) break;
|
|
2525
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2526
|
+
}
|
|
2527
|
+
const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
2528
|
+
if (rows.length !== 2) throw new Error(`clean empty close was ignored: ${rows.join(" | ")}`);
|
|
2529
|
+
if (prompts !== 0) throw new Error(`restored transient close surfaced ${prompts} failure prompts`);
|
|
2530
|
+
writeFileSync(process.env.FM_STOP_FILE, "stop\n");
|
|
2531
|
+
EOF
|
|
2532
|
+
)
|
|
2533
|
+
status=$?
|
|
2534
|
+
expect_code 0 "$status" "OpenCode clean empty close must trigger a bounded continuity retry"
|
|
2535
|
+
[ -z "$out" ] || fail "OpenCode empty-close retry test printed output: $out"
|
|
2536
|
+
pass "OpenCode clean empty close triggers a bounded continuity retry"
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
test_opencode_established_empty_close_honors_retry_limit() {
|
|
2540
|
+
local plugin repo home log out status
|
|
2541
|
+
plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
|
|
2542
|
+
repo="$TMP_ROOT/opencode-established-empty-close-root"
|
|
2543
|
+
home="$TMP_ROOT/opencode-established-empty-close-home"
|
|
2544
|
+
log="$TMP_ROOT/opencode-established-empty-close.log"
|
|
2545
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
2546
|
+
git init -q "$repo"
|
|
2547
|
+
: > "$repo/AGENTS.md"
|
|
2548
|
+
: > "$home/state/task.meta"
|
|
2549
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
2550
|
+
#!/usr/bin/env bash
|
|
2551
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
2552
|
+
printf 'watcher: started pid=%s (beacon fresh)\n' "$$"
|
|
2553
|
+
exit 0
|
|
2554
|
+
SH
|
|
2555
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
2556
|
+
out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node 2>&1 <<'EOF'
|
|
2557
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2558
|
+
import { pathToFileURL } from "node:url";
|
|
2559
|
+
|
|
2560
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
2561
|
+
let prompt = "";
|
|
2562
|
+
const client = {
|
|
2563
|
+
session: {
|
|
2564
|
+
promptAsync: async (request) => {
|
|
2565
|
+
prompt += request.body.parts[0].text;
|
|
2566
|
+
},
|
|
2567
|
+
},
|
|
2568
|
+
};
|
|
2569
|
+
const hooks = await mod.FmPrimaryWatchArm({
|
|
2570
|
+
client,
|
|
2571
|
+
directory: process.env.WORKTREE,
|
|
2572
|
+
worktree: process.env.WORKTREE,
|
|
2573
|
+
});
|
|
2574
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
2575
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
|
|
2576
|
+
for (let i = 0; i < 250 && !prompt; i += 1) {
|
|
2577
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2578
|
+
}
|
|
2579
|
+
const rows = existsSync(process.env.FM_ARM_LOG)
|
|
2580
|
+
? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n")
|
|
2581
|
+
: [];
|
|
2582
|
+
if (rows.length !== 3) throw new Error(`retry limit launched ${rows.length} arm cycles: ${rows.join(" | ")}`);
|
|
2583
|
+
if (!prompt.includes("after 2 retries")) throw new Error(`retry exhaustion was not surfaced: ${prompt}`);
|
|
2584
|
+
EOF
|
|
2585
|
+
)
|
|
2586
|
+
status=$?
|
|
2587
|
+
expect_code 0 "$status" "OpenCode established clean closes must honor the continuity retry limit"
|
|
2588
|
+
[ -z "$out" ] || fail "OpenCode established-empty-close retry test printed output: $out"
|
|
2589
|
+
pass "OpenCode established clean closes stop at the configured retry limit"
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
test_opencode_actionable_close_rechecks_session_lock() {
|
|
2593
|
+
local plugin repo home log release out status
|
|
2594
|
+
plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
|
|
2595
|
+
repo="$TMP_ROOT/opencode-close-lock-root"
|
|
2596
|
+
home="$TMP_ROOT/opencode-close-lock-home"
|
|
2597
|
+
log="$TMP_ROOT/opencode-close-lock.log"
|
|
2598
|
+
release="$TMP_ROOT/opencode-close-lock.release"
|
|
2599
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
2600
|
+
git init -q "$repo"
|
|
2601
|
+
: > "$repo/AGENTS.md"
|
|
2602
|
+
: > "$home/state/task.meta"
|
|
2603
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
2604
|
+
#!/usr/bin/env bash
|
|
2605
|
+
printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}"
|
|
2606
|
+
while [ ! -e "$FM_RELEASE_FILE" ]; do sleep 0.02; done
|
|
2607
|
+
printf 'signal: lock handoff\n'
|
|
2608
|
+
SH
|
|
2609
|
+
chmod +x "$repo/bin/fm-watch-arm.sh"
|
|
2610
|
+
out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_RELEASE_FILE="$release" node 2>&1 <<'EOF'
|
|
2611
|
+
import { spawn } from "node:child_process";
|
|
2612
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2613
|
+
import { pathToFileURL } from "node:url";
|
|
2614
|
+
|
|
2615
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
2616
|
+
let prompt = "";
|
|
2617
|
+
const client = {
|
|
2618
|
+
session: {
|
|
2619
|
+
promptAsync: async (request) => {
|
|
2620
|
+
prompt += request.body.parts[0].text;
|
|
2621
|
+
},
|
|
2622
|
+
},
|
|
2623
|
+
};
|
|
2624
|
+
const hooks = await mod.FmPrimaryWatchArm({
|
|
2625
|
+
client,
|
|
2626
|
+
directory: process.env.WORKTREE,
|
|
2627
|
+
worktree: process.env.WORKTREE,
|
|
2628
|
+
});
|
|
2629
|
+
const lock = `${process.env.FM_HOME}/state/.lock`;
|
|
2630
|
+
writeFileSync(lock, `${process.pid}\n`);
|
|
2631
|
+
const eventPromise = hooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
|
|
2632
|
+
for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) {
|
|
2633
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2634
|
+
}
|
|
2635
|
+
const other = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
|
|
2636
|
+
try {
|
|
2637
|
+
writeFileSync(lock, `${other.pid}\n`);
|
|
2638
|
+
writeFileSync(process.env.FM_RELEASE_FILE, "release\n");
|
|
2639
|
+
await eventPromise;
|
|
2640
|
+
for (let i = 0; i < 250 && !prompt.includes("no longer owns the lock"); i += 1) {
|
|
2641
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2642
|
+
}
|
|
2643
|
+
const rows = readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n");
|
|
2644
|
+
if (rows.length !== 1) throw new Error(`successor launched after lock loss: ${rows.join(" | ")}`);
|
|
2645
|
+
if (!prompt.includes("no longer owns the lock")) throw new Error(`missing lock-loss failure: ${prompt}`);
|
|
2646
|
+
} finally {
|
|
2647
|
+
other.kill("SIGTERM");
|
|
2648
|
+
}
|
|
2649
|
+
EOF
|
|
2650
|
+
)
|
|
2651
|
+
status=$?
|
|
2652
|
+
expect_code 0 "$status" "OpenCode close handler must verify session-lock ownership before successor launch"
|
|
2653
|
+
[ -z "$out" ] || fail "OpenCode close lock test printed output: $out"
|
|
2654
|
+
pass "OpenCode close handler verifies session-lock ownership before successor launch"
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
test_opencode_watch_arm_coordinates_with_turnend_guard() {
|
|
2658
|
+
local arm_plugin guard_plugin repo home log guard_log out status
|
|
2659
|
+
arm_plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
|
|
2660
|
+
guard_plugin="$ROOT/.opencode/plugins/fm-primary-turnend-guard.js"
|
|
2661
|
+
repo="$TMP_ROOT/opencode-coordinate-root"
|
|
2662
|
+
home="$TMP_ROOT/opencode-coordinate-home"
|
|
2663
|
+
log="$TMP_ROOT/opencode-coordinate-arm.log"
|
|
2664
|
+
guard_log="$TMP_ROOT/opencode-coordinate-guard.log"
|
|
2665
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
2666
|
+
git init -q "$repo"
|
|
2667
|
+
: > "$repo/AGENTS.md"
|
|
2668
|
+
: > "$home/state/task.meta"
|
|
2669
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
2670
|
+
#!/usr/bin/env bash
|
|
2671
|
+
printf 'arm\n' >> "${FM_ARM_LOG:?}"
|
|
2672
|
+
printf 'watcher: started pid=1 (beacon fresh)\n'
|
|
2673
|
+
SH
|
|
2674
|
+
cat > "$repo/bin/fm-turnend-guard.sh" <<'SH'
|
|
2675
|
+
#!/usr/bin/env bash
|
|
2676
|
+
printf 'guard\n' >> "${FM_GUARD_LOG:?}"
|
|
2677
|
+
printf 'guard should not run\n' >&2
|
|
2678
|
+
exit 2
|
|
2679
|
+
SH
|
|
2680
|
+
chmod +x "$repo/bin/fm-watch-arm.sh" "$repo/bin/fm-turnend-guard.sh"
|
|
2681
|
+
out=$(ARM_PLUGIN="$arm_plugin" GUARD_PLUGIN="$guard_plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_GUARD_LOG="$guard_log" node 2>&1 <<'EOF'
|
|
2682
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
2683
|
+
import { pathToFileURL } from "node:url";
|
|
2684
|
+
|
|
2685
|
+
const armMod = await import(pathToFileURL(process.env.ARM_PLUGIN).href);
|
|
2686
|
+
const guardMod = await import(pathToFileURL(process.env.GUARD_PLUGIN).href);
|
|
2687
|
+
let promptBody = "";
|
|
2688
|
+
const client = {
|
|
2689
|
+
session: {
|
|
2690
|
+
promptAsync: async (request) => {
|
|
2691
|
+
promptBody = request.body.parts[0].text;
|
|
2692
|
+
},
|
|
2693
|
+
},
|
|
2694
|
+
};
|
|
2695
|
+
await armMod.FmPrimaryWatchArm({
|
|
2696
|
+
client,
|
|
2697
|
+
directory: process.env.WORKTREE,
|
|
2698
|
+
worktree: process.env.WORKTREE,
|
|
2699
|
+
});
|
|
2700
|
+
const guardHooks = await guardMod.FmPrimaryTurnendGuard({
|
|
2701
|
+
client,
|
|
2702
|
+
directory: process.env.WORKTREE,
|
|
2703
|
+
worktree: process.env.WORKTREE,
|
|
2704
|
+
});
|
|
2705
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
2706
|
+
await guardHooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
|
|
2707
|
+
for (let i = 0; i < 250 && !existsSync(process.env.FM_ARM_LOG); i += 1) {
|
|
2708
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
2709
|
+
}
|
|
2710
|
+
if (!existsSync(process.env.FM_ARM_LOG)) {
|
|
2711
|
+
console.error("watch arm did not run");
|
|
2712
|
+
process.exit(1);
|
|
2713
|
+
}
|
|
2714
|
+
if (existsSync(process.env.FM_GUARD_LOG)) {
|
|
2715
|
+
console.error("turn-end guard ran before the watch arm could establish supervision");
|
|
2716
|
+
process.exit(1);
|
|
2717
|
+
}
|
|
2718
|
+
if (promptBody) {
|
|
2719
|
+
console.error(`unexpected prompt: ${promptBody}`);
|
|
2720
|
+
process.exit(1);
|
|
2721
|
+
}
|
|
2722
|
+
EOF
|
|
2723
|
+
)
|
|
2724
|
+
status=$?
|
|
2725
|
+
expect_code 0 "$status" "OpenCode turn-end guard must let the auto-arm plugin establish supervision first"
|
|
2726
|
+
[ -z "$out" ] || fail "OpenCode coordination test printed output: $out"
|
|
2727
|
+
pass "OpenCode watcher plugin coordinates with the turn-end guard"
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
test_opencode_healthy_arm_output_does_not_suppress_guard() {
|
|
2731
|
+
local arm_plugin guard_plugin repo home log guard_log out status
|
|
2732
|
+
arm_plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js"
|
|
2733
|
+
guard_plugin="$ROOT/.opencode/plugins/fm-primary-turnend-guard.js"
|
|
2734
|
+
repo="$TMP_ROOT/opencode-external-healthy-root"
|
|
2735
|
+
home="$TMP_ROOT/opencode-external-healthy-home"
|
|
2736
|
+
log="$TMP_ROOT/opencode-external-healthy-arm.log"
|
|
2737
|
+
guard_log="$TMP_ROOT/opencode-external-healthy-guard.log"
|
|
2738
|
+
mkdir -p "$repo/bin" "$home/state" "$home/config"
|
|
2739
|
+
git init -q "$repo"
|
|
2740
|
+
: > "$repo/AGENTS.md"
|
|
2741
|
+
: > "$home/state/task.meta"
|
|
2742
|
+
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
|
|
2743
|
+
#!/usr/bin/env bash
|
|
2744
|
+
printf 'args=%s\n' "$*" >> "${FM_ARM_LOG:?}"
|
|
2745
|
+
printf 'watcher: healthy pid=1 (beacon 0s)\n'
|
|
2746
|
+
SH
|
|
2747
|
+
cat > "$repo/bin/fm-turnend-guard.sh" <<'SH'
|
|
2748
|
+
#!/usr/bin/env bash
|
|
2749
|
+
printf 'guard\n' >> "${FM_GUARD_LOG:?}"
|
|
2750
|
+
printf 'guard ran after external healthy watcher\n' >&2
|
|
2751
|
+
exit 2
|
|
2752
|
+
SH
|
|
2753
|
+
chmod +x "$repo/bin/fm-watch-arm.sh" "$repo/bin/fm-turnend-guard.sh"
|
|
2754
|
+
out=$(ARM_PLUGIN="$arm_plugin" GUARD_PLUGIN="$guard_plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_GUARD_LOG="$guard_log" node 2>&1 <<'EOF'
|
|
2755
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2756
|
+
import { pathToFileURL } from "node:url";
|
|
2757
|
+
|
|
2758
|
+
const armMod = await import(pathToFileURL(process.env.ARM_PLUGIN).href);
|
|
2759
|
+
const guardMod = await import(pathToFileURL(process.env.GUARD_PLUGIN).href);
|
|
2760
|
+
let promptBody = "";
|
|
2761
|
+
const client = {
|
|
2762
|
+
session: {
|
|
2763
|
+
promptAsync: async (request) => {
|
|
2764
|
+
promptBody = request.body.parts[0].text;
|
|
2765
|
+
},
|
|
2766
|
+
},
|
|
2767
|
+
};
|
|
2768
|
+
await armMod.FmPrimaryWatchArm({
|
|
2769
|
+
client,
|
|
2770
|
+
directory: process.env.WORKTREE,
|
|
2771
|
+
worktree: process.env.WORKTREE,
|
|
2772
|
+
});
|
|
2773
|
+
const guardHooks = await guardMod.FmPrimaryTurnendGuard({
|
|
2774
|
+
client,
|
|
2775
|
+
directory: process.env.WORKTREE,
|
|
2776
|
+
worktree: process.env.WORKTREE,
|
|
2777
|
+
});
|
|
2778
|
+
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);
|
|
2779
|
+
await guardHooks.event({ event: { type: "session.idle", properties: { sessionID: "session-test" } } });
|
|
2780
|
+
for (let i = 0; i < 250 && !existsSync(process.env.FM_GUARD_LOG); i += 1) {
|
|
2781
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
2782
|
+
}
|
|
2783
|
+
if (!existsSync(process.env.FM_ARM_LOG)) {
|
|
2784
|
+
console.error("watch arm did not run");
|
|
2785
|
+
process.exit(1);
|
|
2786
|
+
}
|
|
2787
|
+
if (!readFileSync(process.env.FM_ARM_LOG, "utf8").includes("args=--restart")) {
|
|
2788
|
+
console.error("watch arm was not asked to restart into an owned child");
|
|
2789
|
+
process.exit(1);
|
|
2790
|
+
}
|
|
2791
|
+
if (!existsSync(process.env.FM_GUARD_LOG)) {
|
|
2792
|
+
console.error("turn-end guard was suppressed by an external healthy watcher");
|
|
2793
|
+
process.exit(1);
|
|
2794
|
+
}
|
|
2795
|
+
if (!promptBody.includes("TURN WOULD END BLIND")) {
|
|
2796
|
+
console.error(`missing blind-turn prompt: ${promptBody}`);
|
|
2797
|
+
process.exit(1);
|
|
2798
|
+
}
|
|
2799
|
+
EOF
|
|
2800
|
+
)
|
|
2801
|
+
status=$?
|
|
2802
|
+
expect_code 0 "$status" "OpenCode watch plugin must not treat external healthy output as an owned arm"
|
|
2803
|
+
[ -z "$out" ] || fail "OpenCode external-healthy test printed output: $out"
|
|
2804
|
+
pass "OpenCode healthy arm output does not suppress the turn-end guard"
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
test_pi_extension_reports_external_healthy_watcher
|
|
2808
|
+
test_pi_tool_returns_agent_tool_result
|
|
2809
|
+
test_pi_redundant_tool_call_is_owned_noop
|
|
2810
|
+
test_pi_scheduled_retry_call_is_owned_noop
|
|
2811
|
+
test_pi_actionable_close_starts_single_successor_before_delivery
|
|
2812
|
+
test_pi_branch_offer_owns_actionable_wake
|
|
2813
|
+
test_pi_branch_offer_flags_heartbeat
|
|
2814
|
+
test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_check
|
|
2815
|
+
test_pi_main_only_check_classes_stay_on_main
|
|
2816
|
+
test_pi_heartbeat_restoration_failure_stays_on_main
|
|
2817
|
+
test_pi_watcher_failure_never_offered_to_branch
|
|
2818
|
+
test_pi_handling_delivery_failure_is_typed_once
|
|
2819
|
+
test_pi_hung_successor_falls_back_to_typed_wake
|
|
2820
|
+
test_pi_unretired_successor_falls_back_without_retry
|
|
2821
|
+
test_pi_late_unretired_close_resumes_supervision
|
|
2822
|
+
test_pi_empty_close_retries_instead_of_disappearing
|
|
2823
|
+
test_pi_established_empty_close_honors_retry_limit
|
|
2824
|
+
test_pi_actionable_close_rechecks_session_lock
|
|
2825
|
+
test_pi_arm_distinguishes_session_lock_ownership
|
|
2826
|
+
test_pi_session_transition_generation_owner
|
|
2827
|
+
test_pi_process_exit_cleanup_listener_lifecycle
|
|
2828
|
+
test_pi_process_exit_cleanup_stops_arm_child
|
|
2829
|
+
test_opencode_plugin_package_boundary_is_explicit_esm
|
|
2830
|
+
test_opencode_primary_watch_plugin_uses_effective_state_home
|
|
2831
|
+
test_opencode_primary_watch_plugin_sources_effective_config
|
|
2832
|
+
test_opencode_primary_watch_plugin_requires_session_lock
|
|
2833
|
+
test_opencode_watch_arm_coordinator_respects_primary_scope
|
|
2834
|
+
test_opencode_primary_watch_plugin_rearms_after_wake
|
|
2835
|
+
test_opencode_pre_ready_actionable_close_preserves_its_successor
|
|
2836
|
+
test_opencode_hung_successor_falls_back_to_typed_wake
|
|
2837
|
+
test_opencode_unretired_successor_falls_back_without_retry
|
|
2838
|
+
test_opencode_late_unretired_close_resumes_supervision
|
|
2839
|
+
test_opencode_empty_close_retries_instead_of_disappearing
|
|
2840
|
+
test_opencode_established_empty_close_honors_retry_limit
|
|
2841
|
+
test_opencode_actionable_close_rechecks_session_lock
|
|
2842
|
+
test_opencode_watch_arm_coordinates_with_turnend_guard
|
|
2843
|
+
test_opencode_healthy_arm_output_does_not_suppress_guard
|