fix(goal): quiet waits and unify fleet NG ownership (#56, #57, #58)

This commit is contained in:
Dewey
2026-09-06 04:07:09 -05:00
parent 7345f330fc
commit 9a5fbdbda7
14 changed files with 892 additions and 26 deletions
@@ -0,0 +1,57 @@
# Fleet goal ownership
## Authority and acceptance
Jason confirmed Resume's NG footer and Alt+G, authorized fleet-wide correction, and authorized scoped commits after green suites. Joe/Huey quiet waits are "looking good so far", not final acceptance. Dewey owns this bounded work in mosaic-stack-dev-test. Independent read-only Pi review must approve exact source and deployment pins. CURRENT and the foundation work remain darkwing's.
Outcome: one shared NG implementation at ~/.mosaic/.pi/extensions/goal for existing fleet launch configurations, without duplicate registration. Retain wrapper-guard or mosaic-core role enforcement as configured, and the launcher's unslop hook. Remove the Resume-specific no-discovery override once ordinary discovery works. No automatic restarts, credentials/configuration edits, private-state reads, state migration, policy changes, or push. Local-first deployment remains the operator-authorized workflow; repository CI runners remain deferred, not reported green.
## Measured ownership
41 agent settings paths and 14 role settings paths reference fleet/extensions/goal. They resolve to 24 ordinary settings files; many agent settings are symlinks to role settings. All declared packages lists are empty. Most seat workdirs are the shared brain; Topher and Velma use their seat directories. Topher's extension directory and fifteen role extension links already resolve to shared extensions. Velma still has an ordinary standalone goal tree. The settings template also references fleet/extensions/goal.
Pi 0.85.1 package-manager toResolvedPaths canonicalizes resource paths before deduplication. We will verify this mechanism in the native loader with normal discovery enabled. Replacing settings would needlessly touch 24 files and still leave compatibility entrypoints. Instead, preserve every settings file/link and make the legacy fleet goal path and Velma standalone goal path compatibility symlinks to the shared NG goal. Keep their old ordinary source trees in verified backups. No recursive library forwarding and no separately auto-discovered core entrypoint are introduced.
## State boundary
The shared implementation resolves its NG state directory relative to the selected import path. Native fixture writes proved Pi deduplicates real source paths but Jiti retains the selected alias when resolving imports. At the brain cwd, state is ~/.mosaic/.pi/state/goal. At Topher or Velma's cwd, their project alias selects their own .pi/state/goal. A role cwd behaves the same way. With no project discovery, the legacy compatibility alias selects ~/.mosaic/fleet/state/goal. Every filename remains goal-state.<incarnation>.json. Do not claim all aliases use one state directory.
Legacy agent-home goal state remains untouched. Velma and Topher's existing NG stores remain in place. Source unification does not adopt, resume, delete or migrate existing goals. Existing processes keep their loaded code until the operator safely restarts them. Old goal evidence stays in its original location; do not assume an active goal will transfer. Native fixtures use fresh owned incarnation IDs and clean only their exact fixture files. Live canaries never invoke goal commands or read private state.
The first Velma synthetic fixture failed because it omitted Velma's existing neighboring mosaic-core libraries. The corrected fixture includes those libraries. All shared supporting modules match both installed legacy and Velma libraries byte-for-byte. Pin these dependency trees, wrapper and unslop entrypoints as well as the goal sources, since an alias does not redirect neighboring imports.
## Delivery
1. Pin all three source trees, the current Resume-hotfix launcher, and settings references/link targets. Pin launch.env filesystem identity/change metadata only. Environment files may contain credentials, so the deployment tool must never read or copy their contents. Register an issue and append session/build records.
2. Add an incident-scoped, fail-closed deployment tool and synthetic tests. Default is preflight. Stage two compatibility symlinks outside discovery, then atomically exchange them with the two ordinary trees. Restore the exact original shared launcher as the third change. Check pins before every effect. On failure reverse verified exchanges; on drift refuse rollback. Retain backup receipts and support explicit verified rollback. Never rewrite a run record.
3. Native red control must reproduce the duplicate with ordinary separate trees. Green fixture must load one shared goal through ambient plus explicit aliases, including root, role-linked, Topher-linked, Velma-linked and isolated cwd cases. Preserve configured wrapper/core tool interception plus unslop. Verify NG footer/full recall against identical installed runtime sources. Test hostile drift, symlink/special-file refusals and partial-failure rollback.
4. Independently review exact candidate, hashes, tests, state limits and rollback. Deploy locally only after approval, then rerun actual fleet resource combinations with normal discovery. Do not invoke auth-seeding launchers or restart sessions for testing.
5. Run goal, native, package and five repository suites. Stage only our explicit files, inspect the index, commit with Dewey identity, verify the commit. Do not stage foundation/skill/task dirt or overwrite CURRENT. No push authorized by this commit request.
6. Record Resume acceptance and fleet deployment distinctly. Close #57 for accepted Resume correction when evidence is recorded. Quiet-wait #56 and fleet user acceptance remain open until explicitly accepted.
The first independent review requested removal of launch.env content hashing because environment files can contain credentials. The corrected candidate pins only filesystem metadata and includes tests that reject any attempted environment-content read and detect metadata drift. The rejected manifest remains historical evidence and was never deployed.
## Deployment checkpoint, 2026-09-06 09:02 UTC
Corrected independent review APPROVE: .pi/evidence/goal58/review-v2.log. Deployed the reviewed three-path transaction and verified all after-pins and backups. Legacy fleet goal and Velma goal are now compatibility symlinks to shared NG. The shared launcher is byte-identical to its original pre-workaround version, SHA256 9352feed0acf9d449c26c0556ba00aba1c65ded43d376930a39ff6b5cee21986. No Resume-specific no-discovery override remains.
Reviewed plan SHA256: 43eb7821a484796850e5e9352f51fe8a6cffb2fb9084769070f449b529a47220. Backup and recovery receipt: /home/jwoltje/.mosaic/.pi/goal-backups/goal58-5baaf9ff598644c6ae364b297e3c982a. Its planned.json contains the complete source, dependency and configuration pins. The former plan-v2.json was rejected and never deployed.
Native negative control and six unified fixture cases pass, including owned fixture writes proving the state paths above. Seventeen transaction controls cover rollback, partial interruption, source/config/dependency/mode drift, symlink/special-file refusal, concurrent-writer lock refusal and the environment-content no-read boundary. Post-install native loading passed all 55 actual agent/role settings combinations with their configured wrapper or core tool interception and unslop. No live goal command or model turn ran. Evidence: .pi/evidence/goal58/live-matrix.log. This verifies registration/discovery, not every role-policy decision at runtime.
All 71 goal tests, 18 package controls, native footer/full recall and timed/untimed waiting checks pass. Repository suites pass: config 24, task 90, release 14, conductor 17, auth 15. Whitespace and prose checks pass. Static TypeScript checking and remote CI remain unavailable/deferred, not certified by these tests.
Explicit rollback, only if current source, dependency, configuration and backup pins still match:
```sh
python3 scripts/unify-fleet-goal.py rollback \
--plan /home/jwoltje/.mosaic/.pi/goal-backups/goal58-5baaf9ff598644c6ae364b297e3c982a/planned.json \
--sha256 43eb7821a484796850e5e9352f51fe8a6cffb2fb9084769070f449b529a47220 \
--backup /home/jwoltje/.mosaic/.pi/goal-backups/goal58-5baaf9ff598644c6ae364b297e3c982a
```
Rollback restores both old source trees and the previously accepted Resume workaround. Do not overwrite later concurrent changes to force rollback.
User test: at a safe stopping point, restart another seat through its normal launch command. Expect one NG footer, full /goal and Alt+G recall, no duplicate-tool startup error, and the same configured safeguards. Do not overwrite an existing assignment with a test goal. Old legacy state does not transfer automatically. No session was restarted by Dewey. Resume UX is accepted; fleet rollout is ready for user test and #56 quiet-wait feedback is still preliminary.
Commit scope excludes CURRENT, foundation planning/reviews, unrelated skills/tasks, and the shared logs because they contain other-owner uncommitted entries. Our evidence/acceptance checkpoints are in these owned plans; shared append-only records remain on disk. MS58-DW-1 informational notice to darkwing returned rc=2, submission unconfirmed; no blind resend and no reply requested. Last verified index release remains MS55-DW-3; inspect the index and commit only explicit owned paths. No push authorized. Issue #58. Evidence directory: .pi/evidence/goal58/.
+49
View File
@@ -0,0 +1,49 @@
# Goal quiet waits
## Latest checkpoint, 2026-09-06 09:02 UTC
Jason reports Joe/Huey are "looking good so far" after reloads. This is preliminary positive quiet-wait feedback, not final acceptance. He accepted Resume's NG footer/full recall, authorized fleet ownership correction and scoped suite-gated local commits. #58 now makes the legacy fleet and Velma goal paths compatibility aliases to shared NG and removes the Resume-only workaround. See docs/plans/2026-09-06_fleet-goal-ownership.md for the explicit state-location and no-migration boundary. All 55 live settings/discovery combinations load one shared NG goal with configured guards. The seven-file quiet-wait patch still has its independently approved SHA256 d9caeb0c87e074196421a010e8a2de0968b3bb922010fa864ca2ec1f1902ef6b. Goal, native, package and five repository suites were rerun green. No existing session or private state was changed. #56 remains open for final quiet-wait acceptance; no polling or rearmed timer.
The deployment and authority descriptions below are historical checkpoints. #58 supersedes the three-independent-copy topology and the earlier absence of commit authority.
Owner request: fix the untimed goal-check loop observed in Joe on 2026-09-06. Author: Dewey. Baseline: 7345f330fc6bfae5aa1d896c78cfb7cbe62efbae. Darkwing owns foundation mapping; none of its files are in scope.
## Defect
An accepted untimed wait resets the check counter but does not suppress settle or startup injection. The old quiet-wait test explicitly expects that behavior. Joe's pane showed an accepted wait followed by a new check and a 190k-token cache-miss notice. This is a scheduling defect; a cooldown would merely reduce its frequency.
## Scope and acceptance
- Canonical extension source/tests under extensions/goal, plus this plan and append-only delivery logs.
- All accepted waits suspend automatic checks, independent of timeout configuration.
- Untimed waits create no timer. Unrelated incoming messages and reloads preserve the wait. The model reconciles relevance; the extension never evaluates nextCheck as code or treats incoming text as permission.
- Explicit resume may reconcile a waiting or paused goal once. It clears the old wait; another unresolved report returns to quiet waiting.
- Timed waits retain one deadline wake per goal/resume and their existing abort/readiness/delivery safeguards.
- Guard queued check dispatch against duplicate lifecycle events, without blocking genuine subsequent work or retaining stale dispatches across goal replacement.
- Preserve footer/full recall, completion, pause/blocked, fencing, report-policy and write-failure behavior.
- Verify red-before-green regressions, full extension and repository suites, native no-model checks, independent exact-candidate review, and safe local test installation.
## Delivery boundary
No live fleet files, private goal state, user goals, or sessions are modified during development. The active operator goal explicitly authorizes local-first deployment under ~/.mosaic after verification. Do not automatically resume Joe or reload other seats. Before deployment, compare the actual shared entrypoint and every independent active copy with the reviewed baseline, preserve their state location, pin the file set, save rollback copies and verify native loading. The legacy fleet/extensions copy is distinct from the current .mosaic/.pi/extensions target; do not replace it merely because it exists. No push, CI/merge claim or foundation implementation. This is the operator-requested local development/deployment path, not a published release.
## Tasks
1. Reproduce untimed settle/startup injection and record red tests.
2. Fix wait gating, explicit resume and dispatch deduplication; verify regressions.
3. Review and test the candidate, including fresh native installation.
4. Deploy the pinned candidate to verified current targets, verify symlink resolution and a no-model native canary using isolated fixture state. Report reload requirements without driving existing sessions. Owner acceptance cannot be inferred from tests.
Status: deployed on disk and native-verified; ready for operator reload and acceptance. Issue #56: https://git.mosaicstack.dev/mosaicstack/stack-v2/issues/56. Four red regressions reproduced the old behavior; 71 goal tests, 18 package controls, native Pi timed/untimed Waiting fixtures, and all five repository suites pass. Independent read-only Pi reviewer approved the exact seven-file source/test/documentation candidate at 2026-09-06 07:50 UTC; transcript /tmp/goal56-review.log. Static TypeScript compiler remains unavailable; no claim of static checking.
At 07:51 UTC, the shared .mosaic/.pi/extensions/goal entrypoint, state and settle files matched HEAD byte-for-byte before the fix. Fifteen role extension links resolve to the shared root; Topher links through interact. Velma has an ordinary independent copy whose three runtime files also match HEAD. Joe's running process has cwd ~/.mosaic and no explicit extension flags. A broad initial symlink scan encountered a denied backup subtree; the bounded active roles/agents extension scan completed. The scratch pi-goal-x experiment is not a current shared target. Initial symlink-only inspection was insufficient: a subsequent settings scan found 41 agent and 14 role settings files still referencing legacy fleet/extensions/goal, including Joe. That path therefore also requires the scheduling backport, preserving its distinct state location and widget UI.
Deployment gate, 2026-09-06 08:00 UTC: canonical 71 tests and staged legacy 67 tests pass. The first full legacy staging run had two ENOENT errors because its external-relative skill fixtures were absent; copying the existing canonical fixtures into the expected staging location resolved them without changing live skills. Shared/Velma have an older README and no fixture .gitattributes; runtime files match the baseline. These differences were diagnosed and the actual whole-file trees pinned rather than bypassing a drift refusal. Independent deployment/backport reviewer APPROVE: /tmp/goal56-deploy-review.log.
Pinned deployment input: .pi/evidence/goal56/deployment-pins.json, SHA256 08aec1a0ff7c70ce2a5e1b6831c8ec395a94a2bafe9ca1be9c7110a6b913935c. One-shot deployment script: .pi/evidence/goal56/deploy.py, SHA256 248ec09b534f067d296fc70f2ce50b11810c9927321a58205e860eb19b39684a. Preflight and an isolated atomic-exchange/rollback test passed. Deploy only these three ordinary goal directories; core, settings, symlinks and existing goal states remain untouched. Deployment completed by atomic exchange. Rollback trees and write-once receipts: /home/jwoltje/.mosaic/.pi/goal-backups/goal56-60590600f2c9420a881f1f4f0a5fc5cf. All three deployed trees match their approved after-pins; all three backups match before-pins. All fifteen role links and Topher's agent link resolve to the patched shared tree. Six native Pi RPC canaries loaded the three actual installed entrypoints, timed and untimed: exactly one explicitly selected /goal per isolated canary, zero automatic checks/model starts, and no fixture state changes. Provider credentials were not inherited. All owned fixture states were removed. This does not assert ambient multi-extension discovery is conflict-free, nor that already running sessions have reloaded.
At 2026-09-06 08:02:45 UTC the canonical source still matched the deployed shared/Velma candidate. Canonical working-tree patch: .pi/evidence/goal56/canonical.patch, SHA256 d9caeb0c87e074196421a010e8a2de0968b3bb922010fa864ca2ec1f1902ef6b. Source remains uncommitted; no new commit/push authority was inferred from the earlier baseline authorization. No existing process was interrupted, reloaded, resumed or given a replacement goal. The legacy fleet copy preserves its original store and UI; core, settings and links are unchanged.
Remaining user-facing gate: Jason chooses a safe idle session, runs /reload, and confirms an accepted untimed wait remains quiet. If the chosen goal is paused, resume only when its assignment is still authorized; /goal resume deliberately reconciles once. Do not set a test goal over another agent's assignment. A relevant input should permit verified progress; unrelated input should preserve waiting. This is a manual operator acceptance wait using incoming messages, not a polling watch or another timer. No reload or owner approval is inferred from the native canaries. #56 remains open until that confirmation. The extension's one bounded deadline wake arrived after the 08:06:21 UTC deadline with no operator confirmation or defect report in the conversation. Acceptance remains blocked; no timer was re-armed and no session was inspected, reloaded or resumed. Resume this task on explicit operator confirmation or a defect report.
Communication: darkwing closeout MS55-DW-3 confirms its baseline and released index; no reply requested. The separate fix notice returned rc=2 (delivery unconfirmed); no blind resend. The d4696d09-pinned map remains historical.
@@ -0,0 +1,27 @@
# Resume goal discovery repair
## Accepted outcome, 2026-09-06 09:02 UTC
Jason explicitly confirmed the NG footer and Alt+G full recall. The Resume-specific workaround was then superseded by the authorized fleet fix in #58. Normal discovery now selects the shared NG source through compatibility aliases; the common launcher is restored to its original version with unslop retained, and wrapper/core enforcement stays as configured. All 55 live settings/discovery combinations pass. No settings, credentials or private state were migrated. See docs/plans/2026-09-06_fleet-goal-ownership.md. Resume's accepted defect correction is ready for #57 closure after the scoped commit. Fleet rollout and quiet-wait acceptance remain separate gates.
## Earlier correction — 2026-09-06 08:34 UTC
Jason rejected the legacy selection below and explicitly directed use of the shared NG extension. Resume now selects ~/.mosaic/.pi/extensions/goal with wrapper-guard and unslop retained, exactly once. No state migration or private-state writes. Independent correction review APPROVE; native selected-resource checks verify NG Alt+G and both guards; 71 goal tests and native footer/full recall pass. Installed NG runtime matches tested canonical source. Deployed launcher SHA256 dd9e5ece5f1a86cc286027668560198bb7a44d2ab891f7b1ab4f89d7379ca0e6. Backup: ~/.mosaic/.pi/goal-backups/goal57-shared-4d95a698f4bb4544a0cb90aed5e887a3/launch-seat.sh. Evidence: .pi/evidence/goal57-shared/. Other seats remain unchanged. Restart Resume through its normal launch command when safe; /reload alone in an already running legacy-selected process does not replace its CLI extension selection. Awaiting user retry.
## Earlier repair — legacy selection rejected by owner
Jason requested repair after Resume launch failed with duplicate goal_report registration. Dewey owns this bounded hotfix; foundation/CURRENT and existing agent sessions remain untouched.
The real native loader reproduces the collision when project discovery and Resume's explicit legacy extension are both enabled. A global-settings exclusion of the project entrypoint was tested and failed: Pi applies project auto-discovery exclusions in project scope. No live settings were changed for that experiment.
Chosen repair: Resume-only explicit extension selection in the shared launcher. Retain its declared legacy goal and wrapper-guard with explicit -e arguments while disabling automatic extension discovery for this seat. The launcher's existing explicit unslop hook remains enabled. This is not bare pi -ne: all three existing safeguards are explicitly retained. Refuse if Resume's declared extension list/packages drift from the reviewed two-resource configuration, or if a selected entrypoint is missing. Preserve the legacy state location, all settings/credentials, and all other seats' launch behavior.
Scope: one bounded block in ~/.mosaic/fleet/bin/launch-seat.sh, a canonical native discovery regression script, this plan, and append-only logs. Do not change per-seat shim conventions, mutate private goals, alter credential refusals, migrate state, restart seats, or disable safeguards. Broad fleet source/state unification is not part of this targeted repair; other seats are not silently redirected between the shared and legacy goal stores.
Acceptance: native negative control reproduces the original combined-discovery error; repaired selection loads exactly one goal_report and /goal, retains wrapper tool interception and unslop extension, and produces no loader errors. Validate shell syntax, exact candidate independent review, an atomic backup/replace with drift refusal, and post-install discovery. Do not launch the real auth-seeding pipeline merely to test resource selection. User retries the unchanged Resume launch command for final acceptance.
Status at 2026-09-06 08:29:51 UTC: deployed, ready for Resume retry. Issue #57: https://git.mosaicstack.dev/mosaicstack/stack-v2/issues/57. Independent reviewer APPROVE (.pi/evidence/goal57/review.log). Nine isolated selection controls passed (valid config plus refusals for extension/package drift, invalid JSON, additional project settings/resources/core entrypoint, automatic user extension directory, missing wrapper). Huey's extracted launcher arguments were unchanged. Native negative control reproduced the original collision; post-install native resource loading proved exactly one legacy goal_report and /goal, registered wrapper tool interception, unslop loaded, and no loader errors. A separate real Pi RPC CLI process using the repaired three-extension arguments started successfully without credentials, sessions or model turns. All 71 goal tests still pass; shell syntax and whitespace checks pass.
Launcher before SHA256: 9352feed0acf9d449c26c0556ba00aba1c65ded43d376930a39ff6b5cee21986. Reviewed/deployed SHA256: 9f5c987bb745b8edb6c2093f0dc4990f48229c0e429fbf388feda040cb2bfacb. Verified backup: /home/jwoltje/.mosaic/.pi/goal-backups/goal57-45d15b9891ff4ef392961615abefc8e7/launch-seat.sh. Atomic replacement preserved mode and refused concurrent drift. Rollback requires matching that deployed hash before atomically restoring the verified before-file; do not overwrite another owner's later launcher changes.
Only Resume's launch path changes. Other fleet seats may still have the duplicate-source configuration; do not describe this as fleet-wide consolidation. Existing running sessions were not touched. No goal state, settings, credential files, per-seat shims, source extensions, or foundation files were edited. No commit/push authority inferred. Evidence: .pi/evidence/goal57/. User acceptance: retry ~/.mosaic/fleet/agents/resume/launch.sh normally, without manually adding -ne. The existing credential warning remains correct and fail-closed. #57 stays open pending that retry.
+25 -9
View File
@@ -14,7 +14,7 @@ Requirements record: `docs/PRD.md` in mosaic-brain (issue #52). Design locked wi
/goal <text> set (or replace, with a notify) the active goal /goal <text> set (or replace, with a notify) the active goal
/goal --max N <text> set with a non-default run limit (default 25) /goal --max N <text> set with a non-default run limit (default 25)
/goal stop pause the loop, goal retained /goal stop pause the loop, goal retained
/goal resume continue a paused goal (kicks a check turn) /goal resume reconcile a paused or waiting goal (kicks one check turn)
/goal clear remove the goal entirely /goal clear remove the goal entirely
``` ```
@@ -24,7 +24,7 @@ Requirements record: `docs/PRD.md` in mosaic-brain (issue #52). Design locked wi
1. `before_agent_start` appends the active goal and reporting instructions to the system 1. `before_agent_start` appends the active goal and reporting instructions to the system
prompt every turn, so the goal survives context growth and compaction. prompt every turn, so the goal survives context growth and compaction.
2. When the agent settles without the goal being satisfied, the extension injects a check 2. When the agent settles with ready work and no unresolved wait, the extension injects a check
prompt (`sendUserMessage`), forcing the next turn: the check<->proceed loop. Before reporting, prompt (`sendUserMessage`), forcing the next turn: the check<->proceed loop. Before reporting,
follow the continuation loop in `skills-local/ms-proactive-agent/SKILL.md`. follow the continuation loop in `skills-local/ms-proactive-agent/SKILL.md`.
3. The agent reports via `goal_report`, the loop's only exit: 3. The agent reports via `goal_report`, the loop's only exit:
@@ -87,15 +87,26 @@ The suite includes pure state, persistence, incarnation fencing, exact report-on
duplicate evidence, legitimate wait, headless extension-runtime, and enforcement-neutralization duplicate evidence, legitimate wait, headless extension-runtime, and enforcement-neutralization
red controls. red controls.
## Bounded waits (operator opt-in) ## Quiet waits and optional deadlines
Use `/goal --wait-timeout 60 <goal text>` to suspend automatic checks during a Every accepted wait suspends automatic checks, including untimed waits. No
reported wait. The flag accepts 1086400 seconds and combines with `--max N`. cooldown or model polling is used. A next-check condition describes the dependency;
Omitting it preserves the previous wait-loop behavior. Existing running sessions it is not executed as code, and the extension does not register a watch for it.
must `/reload` to load this patch; do not reload unrelated seats for a trial. Existing external message/watch delivery or explicit `/goal resume` can start
reconciliation. Incoming messages alone do not clear the wait. The model must
verify the dependency and report substantive progress or completion. Unrelated
input preserves the wait. Explicit resume clears the old wait; another unresolved
wait report suspends checks again. Paused and blocked goals still require explicit
operator resume, not merely an incoming message.
Use `/goal --wait-timeout 60 <goal text>` to add one deadline wake to a wait.
The flag accepts 1086400 seconds and combines with `--max N`. Without it,
no deadline timer is armed. Existing running sessions require a controlled reload
after an approved deployment; do not reload unrelated seats for a trial.
A valid `goal_report` with `status: "in_progress"`, `progress.kind: "wait"`, an A valid `goal_report` with `status: "in_progress"`, `progress.kind: "wait"`, an
owner, and a watch id or concrete `nextCheck` persists the deadline and yields. owner, and a watch id or concrete `nextCheck` persists the wait and yields.
A deadline is persisted only when the operator configured one.
There are no model heartbeats during that wait. Repeated wait reports preserve There are no model heartbeats during that wait. Repeated wait reports preserve
its original deadline. A substantive progress report clears the wait. The its original deadline. A substantive progress report clears the wait. The
extension neither evaluates arbitrary shell conditions nor sends messages to extension neither evaluates arbitrary shell conditions nor sends messages to
@@ -124,6 +135,11 @@ Late reports cannot settle a paused goal. Reports also refuse if the goal change
while an asynchronous policy check is running. State snapshots use atomic file while an asynchronous policy check is running. State snapshots use atomic file
replacement; failed saves stop continuation instead of claiming a saved result. replacement; failed saves stop continuation instead of claiming a saved result.
The tests exercise unchanged waits, deadline dispatch and observation, unresolved An in-memory pending-check guard prevents duplicate settle/startup events from
queuing the same check before its matching start is observed. It does not cancel
messages already queued by Pi, span independent extension instances, or supply a
process supervisor. Wait state itself persists across same-incarnation reloads.
The tests exercise timed and untimed waits, deadline dispatch and observation, unresolved
pause, compaction, reload, cancellation, print mode, malformed persisted waits, pause, compaction, reload, cancellation, print mode, malformed persisted waits,
and actual filesystem write failure. This is separate from live seat acceptance. and actual filesystem write failure. This is separate from live seat acceptance.
+17 -8
View File
@@ -25,7 +25,7 @@ import {
type GoalState, type GoalState,
type ProgressDetails, type ProgressDetails,
} from "./lib/state.ts"; } from "./lib/state.ts";
import { decideSettle } from "./lib/settle.ts"; import { decideSettle, hasUnresolvedWait } from "./lib/settle.ts";
import { parseGoalCommand } from "./lib/parse.ts"; import { parseGoalCommand } from "./lib/parse.ts";
import { loadState, saveState, resolveStatePath } from "./lib/store.ts"; import { loadState, saveState, resolveStatePath } from "./lib/store.ts";
import { goalPolicyDenialCode, validateAttestedGoalReport } from "./lib/executive-update.ts"; import { goalPolicyDenialCode, validateAttestedGoalReport } from "./lib/executive-update.ts";
@@ -53,6 +53,8 @@ export default function goalExtension(pi: ExtensionAPI) {
let waitTimer: ReturnType<typeof setTimeout> | undefined; let waitTimer: ReturnType<typeof setTimeout> | undefined;
let armedDeadline: string | undefined; let armedDeadline: string | undefined;
let deadlinePending = false; let deadlinePending = false;
// Process-local queue latch. Only observing this check's start releases it.
let pendingCheck: string | undefined;
function cancelWaitTimer(): void { function cancelWaitTimer(): void {
if (waitTimer !== undefined) clearTimeout(waitTimer); if (waitTimer !== undefined) clearTimeout(waitTimer);
@@ -172,7 +174,7 @@ export default function goalExtension(pi: ExtensionAPI) {
/** Inject one check prompt: immediately when idle, queued behind a running agent otherwise. */ /** Inject one check prompt: immediately when idle, queued behind a running agent otherwise. */
function beginCheck(ctx: ExtensionContext): void { function beginCheck(ctx: ExtensionContext): void {
if (dead || state.status !== "active") return; if (dead || state.status !== "active" || hasUnresolvedWait(state) || pendingCheck) return;
// The loop needs a persistent session. Print mode exits after the prompt // The loop needs a persistent session. Print mode exits after the prompt
// pipeline completes; a forced turn there races teardown (measured). The // pipeline completes; a forced turn there races teardown (measured). The
// goal directive (before_agent_start) still applies in print mode. // goal directive (before_agent_start) still applies in print mode.
@@ -180,7 +182,8 @@ export default function goalExtension(pi: ExtensionAPI) {
state = recordCheckInjected(state); state = recordCheckInjected(state);
if (!persist(ctx)) return; if (!persist(ctx)) return;
renderWidget(ctx); renderWidget(ctx);
const msg = checkPrompt(); const msg = `${checkPrompt()} [goal dispatch ${randomUUID()}]`;
pendingCheck = msg;
if (ctx.isIdle()) { if (ctx.isIdle()) {
try { try {
pi.sendUserMessage(msg); pi.sendUserMessage(msg);
@@ -216,6 +219,7 @@ export default function goalExtension(pi: ExtensionAPI) {
function pauseWith(ctx: ExtensionContext, reason: string, notice: string): void { function pauseWith(ctx: ExtensionContext, reason: string, notice: string): void {
state = pauseGoal(state, reason); state = pauseGoal(state, reason);
pendingCheck = undefined;
persist(ctx); persist(ctx);
renderWidget(ctx); renderWidget(ctx);
safeNotify(ctx, `goal: ${notice}`, "warning"); safeNotify(ctx, `goal: ${notice}`, "warning");
@@ -247,16 +251,18 @@ export default function goalExtension(pi: ExtensionAPI) {
return; return;
} }
state = clearGoal(state); state = clearGoal(state);
pendingCheck = undefined;
if (!persist(ctx)) return; if (!persist(ctx)) return;
renderWidget(ctx); renderWidget(ctx);
safeNotify(ctx, "goal: cleared — normal operation", "info"); safeNotify(ctx, "goal: cleared — normal operation", "info");
return; return;
case "resume": case "resume":
if (state.status !== "paused" && state.status !== "blocked") { if (state.status !== "paused" && state.status !== "blocked" && !hasUnresolvedWait(state)) {
safeNotify(ctx, "goal: nothing paused to resume", "warning"); safeNotify(ctx, "goal: nothing paused or waiting to resume", "warning");
return; return;
} }
state = resumeGoal(state); state = resumeGoal(state);
pendingCheck = undefined;
if (!persist(ctx)) return; if (!persist(ctx)) return;
renderWidget(ctx); renderWidget(ctx);
safeNotify(ctx, "goal: resumed", "info"); safeNotify(ctx, "goal: resumed", "info");
@@ -265,6 +271,7 @@ export default function goalExtension(pi: ExtensionAPI) {
case "set": { case "set": {
const replacing = state.status !== "none"; const replacing = state.status !== "none";
state = setGoal(state, cmd.text, cmd.max, cmd.waitTimeoutSeconds); state = setGoal(state, cmd.text, cmd.max, cmd.waitTimeoutSeconds);
pendingCheck = undefined;
if (!persist(ctx)) return; if (!persist(ctx)) return;
renderWidget(ctx); renderWidget(ctx);
safeNotify( safeNotify(
@@ -414,7 +421,7 @@ export default function goalExtension(pi: ExtensionAPI) {
: outcome.classification === "waiting" : outcome.classification === "waiting"
? (state.waitTimeoutSeconds && ctx.mode !== "print" ? (state.waitTimeoutSeconds && ctx.mode !== "print"
? `Waiting recorded. Automatic goal checks are suspended until a relevant incoming message or the deadline at ${new Date(state.activeWait!.deadlineAt!).toISOString()}. The extension owns this single deadline wake; do not add timers or report unchanged status. Checkpoint and yield now.` ? `Waiting recorded. Automatic goal checks are suspended until a relevant incoming message or the deadline at ${new Date(state.activeWait!.deadlineAt!).toISOString()}. The extension owns this single deadline wake; do not add timers or report unchanged status. Checkpoint and yield now.`
: (ctx.mode === "print" ? "Waiting recorded. Print mode cannot schedule a wake; resume this goal in a persistent session." : `Recorded explicit waiting state (${outcome.reason}). Keep the approved watch or next-check condition active.`)) : (ctx.mode === "print" ? "Waiting recorded. Print mode cannot schedule a wake; resume this goal in a persistent session." : "Waiting recorded. Automatic goal checks are suspended. No deadline timer is armed. Keep the existing message/watch delivery path; nextCheck is descriptive, not executable. Reconcile on relevant input or explicit /goal resume. Do not poll or report unchanged status. Checkpoint and yield now."))
: `No new progress recorded (${outcome.reason}). No-progress count is ${state.noProgressReports}/${state.maxNoProgressReports}; take the next safe action before reporting again.`; : `No new progress recorded (${outcome.reason}). No-progress count is ${state.noProgressReports}/${state.maxNoProgressReports}; take the next safe action before reporting again.`;
return { return {
content: [{ type: "text", text }], content: [{ type: "text", text }],
@@ -426,6 +433,7 @@ export default function goalExtension(pi: ExtensionAPI) {
// ---------- loop wiring ---------- // ---------- loop wiring ----------
pi.on("before_agent_start", async (event, ctx) => { pi.on("before_agent_start", async (event, ctx) => {
if (pendingCheck && event.prompt.includes(pendingCheck)) pendingCheck = undefined;
if (state.status !== "active") { if (state.status !== "active") {
if (event.prompt.startsWith("[goal check ")) { if (event.prompt.startsWith("[goal check ")) {
return { systemPrompt: `${event.systemPrompt}\nThis queued goal check is obsolete: the goal is paused or cleared. Do not execute it or call goal_report.` }; return { systemPrompt: `${event.systemPrompt}\nThis queued goal check is obsolete: the goal is paused or cleared. Do not execute it or call goal_report.` };
@@ -438,7 +446,7 @@ export default function goalExtension(pi: ExtensionAPI) {
} }
const waitingDirective = state.waitTimeoutSeconds && ctx.mode !== "print" const waitingDirective = state.waitTimeoutSeconds && ctx.mode !== "print"
? " Bounded waits enabled: report in_progress with progress.kind=wait and a concrete nextCheck when no ready work remains. The extension suspends checks and supplies one deadline wake per goal/resume. Do not create a duplicate timer, sleep loop, or heartbeat. On unrelated incoming messages, keep the wait; on a relevant event verify the dependency. After the deadline wake, an unresolved wait pauses." ? " Bounded waits enabled: report in_progress with progress.kind=wait and a concrete nextCheck when no ready work remains. The extension suspends checks and supplies one deadline wake per goal/resume. Do not create a duplicate timer, sleep loop, or heartbeat. On unrelated incoming messages, keep the wait; on a relevant event verify the dependency. After the deadline wake, an unresolved wait pauses."
: ""; : " Accepted waits suspend automatic goal checks without a deadline timer. Incoming messages do not clear the wait by themselves: on unrelated input preserve it; on relevant input verify the dependency before reporting progress. Do not poll, add timers, or report unchanged status. The operator can use /goal resume to reconcile once.";
const directive = const directive =
`[goal] ACTIVE OPERATOR GOAL: ${state.text}\n` + `[goal] ACTIVE OPERATOR GOAL: ${state.text}\n` +
`Work toward this goal. Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop. ` + `Work toward this goal. Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop. ` +
@@ -478,6 +486,7 @@ export default function goalExtension(pi: ExtensionAPI) {
// Teardown guard: after session replacement/reload/quit, stop touching ctx. // Teardown guard: after session replacement/reload/quit, stop touching ctx.
pi.on("session_shutdown", async () => { pi.on("session_shutdown", async () => {
dead = true; dead = true;
pendingCheck = undefined;
cancelWaitTimer(); cancelWaitTimer();
}); });
@@ -492,7 +501,7 @@ export default function goalExtension(pi: ExtensionAPI) {
safeNotify(ctx, `goal: active — ${truncate(state.text, 60)}`, "info"); safeNotify(ctx, `goal: active — ${truncate(state.text, 60)}`, "info");
// Durability (Q7b): a fresh session with an active goal re-engages the // Durability (Q7b): a fresh session with an active goal re-engages the
// loop immediately — no prior turn exists to emit agent_settled. // loop immediately — no prior turn exists to emit agent_settled.
if (!(state.waitTimeoutSeconds && state.activeWait && !state.activeWait.wakeSent)) beginCheck(ctx); if (!hasUnresolvedWait(state)) beginCheck(ctx);
} else if (state.status === "paused" || state.status === "blocked") { } else if (state.status === "paused" || state.status === "blocked") {
safeNotify(ctx, "goal: paused — /goal resume to continue", "info"); safeNotify(ctx, "goal: paused — /goal resume to continue", "info");
} }
+6 -1
View File
@@ -5,6 +5,11 @@ import { checkLimitReached, type GoalState } from "./state.ts";
export type SettleDecision = { action: "inject" } | { action: "wait" } | { action: "pause"; reason: string }; export type SettleDecision = { action: "inject" } | { action: "wait" } | { action: "pause"; reason: string };
/** An untimed wait never spends model tokens on automatic reconciliation. */
export function hasUnresolvedWait(state: GoalState): boolean {
return !!state.activeWait && (!state.waitTimeoutSeconds || !state.activeWait.wakeSent);
}
/** /**
* Decide what the loop does when the agent settles while a goal is active. * Decide what the loop does when the agent settles while a goal is active.
* stopReason comes from the last assistant message ("stop", "toolUse", * stopReason comes from the last assistant message ("stop", "toolUse",
@@ -17,7 +22,7 @@ export function decideSettle(state: GoalState, stopReason: string | undefined):
if (stopReason === "error") { if (stopReason === "error") {
return { action: "pause", reason: "paused: run error" }; return { action: "pause", reason: "paused: run error" };
} }
if (state.waitTimeoutSeconds && state.activeWait && !state.activeWait.wakeSent) { if (hasUnresolvedWait(state)) {
return { action: "wait" }; return { action: "wait" };
} }
if (checkLimitReached(state)) { if (checkLimitReached(state)) {
+4 -3
View File
@@ -77,7 +77,7 @@ export interface GoalState {
wakeDispatchedAt?: number; wakeDispatchedAt?: number;
wakeObserved?: boolean; wakeObserved?: boolean;
}; };
/** Operator opt-in: suspend waits, with one deadline wake per goal/resume. */ /** Operator opt-in: add one deadline wake to a suspended wait per goal/resume. */
waitTimeoutSeconds?: number; waitTimeoutSeconds?: number;
waitWakeUsed?: boolean; waitWakeUsed?: boolean;
setAt: string; setAt: string;
@@ -144,7 +144,8 @@ export function blockGoal(state: GoalState, reason: string): GoalState {
} }
export function resumeGoal(state: GoalState): GoalState { export function resumeGoal(state: GoalState): GoalState {
if (state.status !== "paused" && state.status !== "blocked") return state; if (state.status !== "paused" && state.status !== "blocked" &&
!(state.status === "active" && state.activeWait)) return state;
return { return {
...state, ...state,
status: "active", status: "active",
@@ -152,7 +153,7 @@ export function resumeGoal(state: GoalState): GoalState {
noProgressReports: 0, noProgressReports: 0,
workEventSinceReport: false, workEventSinceReport: false,
pausedReason: undefined, pausedReason: undefined,
activeWait: state.waitTimeoutSeconds ? undefined : state.activeWait, activeWait: undefined,
waitWakeUsed: state.waitTimeoutSeconds ? false : state.waitWakeUsed, waitWakeUsed: state.waitTimeoutSeconds ? false : state.waitWakeUsed,
}; };
} }
+1 -1
View File
@@ -23,5 +23,5 @@ test("timeout input is bounded and opt-in never leaks to another goal", () => {
const opted = setGoal(initialState(), "one", undefined, 60); const opted = setGoal(initialState(), "one", undefined, 60);
assert.equal(setGoal(opted, "two").waitTimeoutSeconds, undefined); assert.equal(setGoal(opted, "two").waitTimeoutSeconds, undefined);
const legacyWait = recordInProgressReport(setGoal(initialState(), "legacy"), wait).state; const legacyWait = recordInProgressReport(setGoal(initialState(), "legacy"), wait).state;
assert.equal(decideSettle(legacyWait, "stop").action, "inject"); assert.equal(decideSettle(legacyWait, "stop").action, "wait");
}); });
+122 -1
View File
@@ -51,6 +51,7 @@ async function createHeadlessHarness(root: string, incarnation: string) {
const commands = new Map<string, any>(); const commands = new Map<string, any>();
const tools = new Map<string, any>(); const tools = new Map<string, any>();
const sent: SentMessage[] = []; const sent: SentMessage[] = [];
const observedChecks = new Set<string>();
const notifications: Array<{ message: string; level: string }> = []; const notifications: Array<{ message: string; level: string }> = [];
const widgets: unknown[] = []; const widgets: unknown[] = [];
const statuses: string[] = []; const statuses: string[] = [];
@@ -70,7 +71,18 @@ async function createHeadlessHarness(root: string, incarnation: string) {
}, },
registerShortcut(key: string, shortcut: any) { shortcuts.set(key, shortcut); }, registerShortcut(key: string, shortcut: any) { shortcuts.set(key, shortcut); },
registerTool(tool: any) { registerTool(tool: any) {
tools.set(tool.name, tool); // Reports execute inside a model turn. Deliver its queued check's start
// rather than bypassing before_agent_start as the old fixture did.
tools.set(tool.name, { ...tool, execute: async (...args: any[]) => {
const queued = sent.at(-1)?.content;
if (queued && !observedChecks.has(queued)) {
observedChecks.add(queued);
for (const handler of handlers.get("before_agent_start") ?? []) {
await handler({ prompt: queued, systemPrompt: "base" }, ctx);
}
}
return tool.execute(...args);
} });
}, },
sendUserMessage(content: string, options?: SentMessage["options"]) { sendUserMessage(content: string, options?: SentMessage["options"]) {
sent.push({ content, options }); sent.push({ content, options });
@@ -113,6 +125,7 @@ async function createHeadlessHarness(root: string, incarnation: string) {
idle = value; idle = value;
}, },
async emit(name: string, event: any = { type: name }) { async emit(name: string, event: any = { type: name }) {
if (name === "before_agent_start") observedChecks.add(event.prompt);
for (const handler of handlers.get(name) ?? []) await handler(event, ctx); for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
}, },
state() { state() {
@@ -324,6 +337,114 @@ const waitReport = {
nextAction: "Read input and finish output", nextCheck: "input file available" }, nextAction: "Read input and finish output", nextCheck: "input file available" },
}; };
test("queued check latch deduplicates lifecycle events until the matching start", async () => {
const dir = mkdtempSync(join(tmpdir(), "goal-check-latch-"));
const harness = await createHeadlessHarness(dir, "check-latch");
try {
await harness.commands.get("goal").handler("ready work", harness.ctx);
const first = harness.sent[0].content;
for (let i = 0; i < 10; i++) {
await harness.emit("session_start");
await harness.emit("agent_settled");
}
assert.equal(harness.sent.length, 1);
await harness.emit("before_agent_start", { prompt: "unrelated incoming question", systemPrompt: "base" });
await harness.emit("agent_settled");
assert.equal(harness.sent.length, 1, "unrelated input does not release a pending check");
await harness.emit("before_agent_start", { prompt: first, systemPrompt: "base" });
await harness.emit("agent_settled");
assert.equal(harness.sent.length, 2, "a completed check can continue ready work");
await harness.emit("before_agent_start", { prompt: first, systemPrompt: "base" });
await harness.emit("agent_settled");
assert.equal(harness.sent.length, 2, "duplicate old start cannot release a newer check");
await harness.commands.get("goal").handler("replacement", harness.ctx);
assert.equal(harness.sent.length, 3, "explicit replacement may schedule its new goal");
await harness.commands.get("goal").handler("stop", harness.ctx);
await harness.commands.get("goal").handler("resume", harness.ctx);
assert.equal(harness.sent.length, 4);
} finally {
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
}
});
test("untimed wait: settle and duplicate reports never inject a heartbeat", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
const dir = mkdtempSync(join(tmpdir(), "goal-untimed-"));
const harness = await createHeadlessHarness(dir, "untimed");
try {
await harness.commands.get("goal").handler("wait for coordinator", harness.ctx);
const report = harness.tools.get("goal_report");
await report.execute("wait", waitReport, undefined, undefined, harness.ctx);
const before = harness.sent.length;
for (let i = 0; i < 100; i++) {
await harness.emit("agent_settled");
await report.execute(`repeat-${i}`, waitReport, undefined, undefined, harness.ctx);
}
t.mock.timers.tick(7 * 86400000);
assert.equal(harness.sent.length, before, "accepted waits must not generate model turns");
assert.equal(harness.state().checks, 0);
assert.equal(harness.state().noProgressReports, 0);
assert.equal(harness.state().activeWait.deadlineAt, undefined);
} finally {
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
}
});
test("untimed wait: reload, unrelated input and tool work preserve suspension", async () => {
const dir = mkdtempSync(join(tmpdir(), "goal-untimed-reload-"));
const harness = await createHeadlessHarness(dir, "untimed-reload");
try {
await harness.commands.get("goal").handler("wait for coordinator", harness.ctx);
const report = harness.tools.get("goal_report");
await report.execute("wait", waitReport, undefined, undefined, harness.ctx);
const wait = harness.state().activeWait;
const before = harness.sent.length;
await harness.emit("session_shutdown");
await harness.emit("session_start");
await harness.emit("session_start");
assert.equal(harness.sent.length, before, "startup must preserve even an untimed wait");
await harness.emit("before_agent_start", { prompt: "unrelated question", systemPrompt: "base" });
await harness.emit("tool_result", { toolName: "read", isError: false });
await harness.emit("agent_settled");
assert.deepEqual(harness.state().activeWait, wait);
assert.equal(harness.sent.length, before);
await harness.emit("before_agent_start", { prompt: "dependency result arrived", systemPrompt: "base" });
await report.execute("progress", { status: "in_progress", evidence: "dependency verified", progress: { ...waitReport.progress, kind: "measurement", lastMeasurement: "verified input" } }, undefined, undefined, harness.ctx);
assert.equal(harness.state().activeWait, undefined);
await harness.emit("agent_settled");
assert.equal(harness.sent.length, before + 1, "verified progress allows continuation");
} finally {
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
}
});
test("untimed wait: explicit resume reconciles once, paused waits remain owner-controlled", async () => {
const dir = mkdtempSync(join(tmpdir(), "goal-untimed-resume-"));
const harness = await createHeadlessHarness(dir, "untimed-resume");
try {
const goal = harness.commands.get("goal");
const report = harness.tools.get("goal_report");
await goal.handler("wait for coordinator", harness.ctx);
await report.execute("wait", waitReport, undefined, undefined, harness.ctx);
await goal.handler("resume", harness.ctx);
assert.equal(harness.sent.length, 2, "resume must work for an active waiting goal");
assert.equal(harness.state().activeWait, undefined);
await report.execute("still-waiting", waitReport, undefined, undefined, harness.ctx);
await harness.emit("agent_settled");
assert.equal(harness.sent.length, 2);
await goal.handler("stop", harness.ctx);
await harness.emit("before_agent_start", { prompt: "dependency result", systemPrompt: "base" });
await harness.emit("agent_settled");
assert.equal(harness.state().status, "paused");
assert.equal(harness.sent.length, 2);
await goal.handler("resume", harness.ctx);
assert.equal(harness.sent.length, 3);
assert.equal(harness.state().activeWait, undefined);
} finally {
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
}
});
test("quiet wait: no heartbeats, one deadline wake, unresolved dependency pauses", async (t) => { test("quiet wait: no heartbeats, one deadline wake, unresolved dependency pauses", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 }); t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
const dir = mkdtempSync(join(tmpdir(), "goal-quiet-runtime-")); const dir = mkdtempSync(join(tmpdir(), "goal-quiet-runtime-"));
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Native Pi discovery controls using owned source copies and real guard modules.
No provider, session, private goal read or live mutation. The optional first
argument is the installed brain root supplying wrapper/core/unslop modules.
"""
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
repo = Path(__file__).resolve().parent.parent
brain = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.home() / '.mosaic'
with tempfile.TemporaryDirectory(prefix='goal58-native-') as temp:
root = Path(temp)
for name in ['goal', 'mosaic-core']:
shutil.copytree(repo / 'extensions' / name, root / '.pi/extensions' / name)
legacy = root / 'fleet/extensions/goal'
shutil.copytree(repo / 'extensions/goal', legacy)
for name in ['wrapper-guard', 'mosaic-core']:
(root / 'fleet/extensions' / name).symlink_to(brain / 'fleet/extensions' / name)
(root / 'tools').mkdir()
(root / 'tools/unslop-hook').symlink_to(brain / 'tools/unslop-hook')
(root / '.goal-discovery-owned-fixture').write_text('synthetic goal58 fixture')
settings = root / 'settings.json'
def check(cwd, mode, guard='wrapper-guard'):
settings.write_text(json.dumps({'extensions': [str(legacy), str(root / 'fleet/extensions' / guard)]}))
subprocess.run(['node', str(repo / 'scripts/test-goal-discovery.mjs'),
str(cwd), str(settings), mode, str(root)], check=True, timeout=30,
env={**os.environ, 'MOSAIC_GOAL_DISCOVERY_FIXTURE_ROOT': str(root)})
check(root, 'conflict')
legacy.rename(root / 'old-goal-source')
legacy.symlink_to(root / '.pi/extensions/goal')
check(root, 'unified')
check(root, 'unified', 'mosaic-core')
for relative in ['fleet/agents/topher', 'fleet/roles/interact']:
cwd = root / relative
(cwd / '.pi').mkdir(parents=True)
(cwd / '.pi/extensions').symlink_to(root / '.pi/extensions')
check(cwd, 'unified')
velma = root / 'fleet/agents/velma'
(velma / '.pi/extensions').mkdir(parents=True)
(velma / '.pi/extensions/goal').symlink_to(root / '.pi/extensions/goal')
shutil.copytree(repo / 'extensions/mosaic-core', velma / '.pi/extensions/mosaic-core')
check(velma, 'unified')
isolated = root / 'isolated'
isolated.mkdir()
check(isolated, 'unified')
print('PASS native negative control and six unified discovery cases; no live source/state changes')
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Synthetic transactional controls for the #58 fleet source-alias repair."""
import importlib.util
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('unify', Path(__file__).with_name('unify-fleet-goal.py'))
u = importlib.util.module_from_spec(spec)
spec.loader.exec_module(u)
class OwnershipTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='goal58-transaction-')
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
for p in (u.SHARED, *u.ALIASES):
directory = self.root / p
directory.mkdir(parents=True)
(directory / 'index.ts').write_text('// fixture ' + p)
launcher = self.root / u.LAUNCHER
launcher.parent.mkdir(parents=True)
before = (b'#!/bin/bash\nARGS=()\n'
b'# Issue #57: Resume uses the operator-selected shared NG goal\n'
b'if false; then :; fi\nfor f in "${PROMPT_FILES[@]}"; do :; done\n')
after = b'#!/bin/bash\nARGS=()\nfor f in "${PROMPT_FILES[@]}"; do :; done\n'
launcher.write_bytes(before)
launcher.chmod(0o755)
self.addCleanup(patch.stopall)
patch.object(u, 'HOTFIX_SHA', u.sha(before)).start()
patch.object(u, 'ORIGINAL_SHA', u.sha(after)).start()
self.settings = self.root / 'fleet/roles/code/.pi/agent/settings.json'
self.settings.parent.mkdir(parents=True)
self.settings.write_text(json.dumps({'extensions': ['~/.mosaic/fleet/extensions/goal'], 'packages': []}))
link = self.root / 'fleet/agents/joe/.pi/agent/settings.json'
link.parent.mkdir(parents=True)
link.symlink_to(self.settings)
template = self.root / 'fleet/templates/pi-settings.json.template'
template.parent.mkdir(parents=True)
template.write_text('{"extensions":["~/.mosaic/fleet/extensions/goal"]}')
self.env_file = self.root / 'fleet/agents/joe/launch.env'
self.env_file.write_text('# synthetic content must not be read\n')
self.states = [link.parent / 'goal-state.json', self.root / '.pi/state/goal/goal-state.fixture.json']
for state in self.states:
state.parent.mkdir(parents=True, exist_ok=True)
state.write_text('owned synthetic state sentinel')
for p in u.DEPENDENCIES:
target = self.root / p
if target.suffix == '.ts':
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text('// synthetic guard')
else:
target.mkdir(parents=True, exist_ok=True)
(target / 'module.ts').write_text('// synthetic core')
self.plan = u.prepare(self.root)
self.raw = json.dumps(self.plan).encode()
def test_apply_restore_and_state_preservation(self):
backup = u.apply(self.plan, self.raw)
u.verify_after(self.plan)
self.assertEqual((backup / 'planned.json').read_bytes(), self.raw)
for state in self.states:
self.assertEqual(state.read_text(), 'owned synthetic state sentinel')
u.recover(self.plan, backup)
u.verify_before(self.plan)
def test_partial_failure_reverses_exchange(self):
original = u.exchange
calls = 0
def exchange(a, b):
nonlocal calls
calls += 1
if calls == 2:
raise OSError('injected second exchange failure')
original(a, b)
with patch.object(u, 'exchange', exchange):
with self.assertRaisesRegex(OSError, 'injected'):
u.apply(self.plan, self.raw)
u.verify_before(self.plan)
self.assertEqual(calls, 3)
def test_receipt_recovers_interrupted_partial_deployment(self):
backup = u.apply(self.plan, self.raw)
# Simulate interruption after aliases changed, before launcher exchange.
u.exchange(self.root / u.LAUNCHER, u.stage_path(backup, u.LAUNCHER))
u.recover(self.plan, backup)
u.verify_before(self.plan)
def test_settings_drift_refuses(self):
self.settings.write_text('{}')
with self.assertRaisesRegex(RuntimeError, 'inventory drift'):
u.apply(self.plan, self.raw)
self.assertFalse((self.root / u.ALIASES[0]).is_symlink())
def test_environment_contents_are_never_read(self):
original = Path.read_bytes
def read_bytes(path):
if path.name == 'launch.env':
self.fail('environment contents read')
return original(path)
with patch.object(Path, 'read_bytes', read_bytes):
u.verify_before(self.plan)
def test_environment_metadata_drift_refuses(self):
self.env_file.write_text('# changed synthetic environment\n')
with self.assertRaisesRegex(RuntimeError, 'inventory drift'):
u.apply(self.plan, self.raw)
def test_shared_source_drift_refuses(self):
(self.root / u.SHARED / 'index.ts').write_text('drift')
with self.assertRaisesRegex(RuntimeError, 'shared source drift'):
u.apply(self.plan, self.raw)
def test_dependency_drift_refuses(self):
(self.root / u.DEPENDENCIES[0] / 'module.ts').write_text('drift')
with self.assertRaisesRegex(RuntimeError, 'dependency drift'):
u.apply(self.plan, self.raw)
def test_target_drift_refuses(self):
(self.root / u.ALIASES[0] / 'index.ts').write_text('drift')
with self.assertRaisesRegex(RuntimeError, 'before drift'):
u.apply(self.plan, self.raw)
def test_mode_drift_refuses(self):
(self.root / u.ALIASES[0] / 'index.ts').chmod(0o700)
with self.assertRaisesRegex(RuntimeError, 'before drift'):
u.verify_before(self.plan)
def test_nested_symlink_refuses(self):
(self.root / u.ALIASES[0] / 'link').symlink_to(self.settings)
with self.assertRaisesRegex(RuntimeError, 'symlink/special'):
u.prepare(self.root)
def test_special_file_refuses(self):
os.mkfifo(self.root / u.ALIASES[0] / 'fifo')
with self.assertRaisesRegex(RuntimeError, 'symlink/special'):
u.prepare(self.root)
def test_repeat_apply_refuses(self):
u.apply(self.plan, self.raw)
with self.assertRaisesRegex(RuntimeError, 'non-canonical source'):
u.apply(self.plan, self.raw)
def test_rollback_validates_all_pairs_before_effect(self):
backup = u.apply(self.plan, self.raw)
(u.stage_path(backup, u.LAUNCHER)).write_text('backup drift')
with self.assertRaisesRegex(RuntimeError, 'rollback backup drift'):
u.recover(self.plan, backup)
u.verify_after(self.plan)
def test_rollback_refuses_alias_drift(self):
backup = u.apply(self.plan, self.raw)
target = self.root / u.ALIASES[1]
target.unlink()
target.symlink_to(self.root / 'unreviewed')
with self.assertRaises(RuntimeError):
u.recover(self.plan, backup)
self.assertTrue(u.is_alias(self.root / u.ALIASES[0], self.root / u.SHARED))
def test_concurrent_deployment_lock_refuses(self):
with u.deployment_lock(self.plan):
with self.assertRaises(BlockingIOError):
with u.deployment_lock(self.plan):
self.fail('second writer acquired the lock')
def test_unknown_launcher_refuses(self):
(self.root / u.LAUNCHER).write_text('# not reviewed\n')
with self.assertRaisesRegex(RuntimeError, 'reviewed Resume hotfix'):
u.prepare(self.root)
if __name__ == '__main__':
unittest.main()
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env node
// No-model native resource-loader check: real project discovery plus seat extensions.
// Usage: node scripts/test-goal-discovery.mjs <cwd> <settings.json> conflict|explicit|shared|unified [brain]
// Reads only the extensions list; never copies credentials, starts sessions or runs tools.
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, realpathSync, rmSync, existsSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative } from 'node:path';
import { pathToFileURL } from 'node:url';
import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
const [cwd, settingsPath, expected, brain = cwd] = process.argv.slice(2);
assert.ok(cwd && settingsPath && ['conflict', 'explicit', 'shared', 'unified'].includes(expected));
const wantsShared = expected === 'shared' || expected === 'unified';
const extensions = JSON.parse(readFileSync(settingsPath, 'utf8')).extensions;
assert.ok(Array.isArray(extensions) && extensions.every(x => typeof x === 'string'));
const agentDir = mkdtempSync(join(tmpdir(), 'goal-discovery-'));
process.env.PI_CODING_AGENT_DIR = agentDir;
process.env.MOSAIC_LAUNCH_INCARNATION = 'discovery-test-' + randomUUID();
process.env.PI_OFFLINE = '1';
// Loading the shared extension must not quarantine any operator-owned legacy state.
for (const dir of new Set([cwd, brain])) {
assert.equal(existsSync(join(dir, '.pi/state/goal/goal-state.json')), false);
}
try {
const binary = realpathSync(execFileSync('which', ['pi'], { encoding: 'utf8' }).trim());
const { DefaultResourceLoader, SettingsManager } = await import(pathToFileURL(join(dirname(binary), 'index.js')).href);
const settingsManager = SettingsManager.inMemory({ extensions });
settingsManager.setProjectTrusted(true);
const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager,
noExtensions: expected === 'explicit' || expected === 'shared',
additionalExtensionPaths: [...(expected === 'shared'
? [join(brain, '.pi/extensions/goal'), join(brain, 'fleet/extensions/wrapper-guard')]
: expected === 'explicit' ? extensions : []), join(brain, 'tools/unslop-hook/extension.ts')],
noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true });
await loader.reload();
const loaded = loader.getExtensions();
if (expected === 'conflict') {
assert.ok(loaded.errors.some(e => e.error.includes('Tool "goal_report" conflicts with')), JSON.stringify(loaded.errors));
console.log('PASS negative control: combined discovery reproduces goal_report conflict');
} else {
assert.deepEqual(loaded.errors, []);
const owners = loaded.extensions.filter(e => e.tools.has('goal_report'));
assert.equal(owners.length, 1);
assert.equal(realpathSync(owners[0].path.replace(/\/index\.ts$/, '')),
realpathSync(join(brain, wantsShared ? '.pi/extensions/goal' : 'fleet/extensions/goal')));
const guardNames = extensions.filter(p => /\/(wrapper-guard|mosaic-core)$/.test(p)).map(p => p.split('/').at(-1));
assert.ok(guardNames.length, 'test must include the configured enforcement extension');
for (const name of guardNames) {
const guards = loaded.extensions.filter(e => e.path.replace(/\/index\.ts$/, '').endsWith('/' + name));
assert.equal(guards.length, 1, `${name} must remain loaded`);
assert.ok(guards[0].handlers.get('tool_call')?.length, `${name} interception must remain registered`);
}
assert.equal(owners[0].commands.has('goal'), true);
assert.equal(loaded.extensions.filter(e => e.path.endsWith('/unslop-hook/extension.ts')).length, 1,
'launcher unslop extension must remain loaded');
if (wantsShared) {
assert.ok(owners[0].shortcuts.has('alt+g'), 'NG full recall shortcut must be registered');
}
// Only the synthetic native suite opts into an owned fixture write. Live checks
// merely load registrations and never run a goal command or read private state.
if (process.env.MOSAIC_GOAL_DISCOVERY_FIXTURE_ROOT === brain) {
assert.ok(brain.startsWith(join(tmpdir(), 'goal58-native-')));
assert.equal(readFileSync(join(brain, '.goal-discovery-owned-fixture'), 'utf8'), 'synthetic goal58 fixture');
const filename = `goal-state.${process.env.MOSAIC_LAUNCH_INCARNATION}.json`;
const possible = [...new Set([join(brain, '.pi/state/goal', filename),
join(brain, 'fleet/state/goal', filename), join(cwd, '.pi/state/goal', filename)])];
assert.ok(possible.every(p => !existsSync(p)), 'fixture identity must be new');
await owners[0].commands.get('goal').handler('owned goal58 fixture', { mode: 'print', hasUI: false });
const written = possible.filter(existsSync);
assert.equal(written.length, 1, 'one incarnation state store per selected entrypoint');
assert.equal(JSON.parse(readFileSync(written[0], 'utf8')).text, 'owned goal58 fixture');
console.log(`PASS owned fixture state path: ${relative(brain, dirname(written[0]))}`);
unlinkSync(written[0]);
}
console.log(`PASS ${expected} discovery: exactly one ${wantsShared ? 'shared NG' : 'legacy'} goal_report and /goal; ${guardNames.join(', ')} interception and unslop retained`);
}
} finally {
rmSync(agentDir, { recursive: true, force: true });
}
+9 -3
View File
@@ -62,7 +62,7 @@ with tempfile.TemporaryDirectory(prefix="ng-native-pi-") as home:
rpc.terminate() rpc.terminate()
rpc.communicate(timeout=10) rpc.communicate(timeout=10)
for status, label, width, no_color in [("paused", "Paused", 120, False), ("blocked", "Blocked", 45, False), ("none", "Complete", 120, True), ("active", "Waiting", 45, False)]: for status, label, width, no_color, timed in [("paused", "Paused", 120, False, False), ("blocked", "Blocked", 45, False, False), ("none", "Complete", 120, True, False), ("active", "Waiting", 45, False, True), ("active", "Waiting", 120, False, False)]:
incarnation = "ng-native-test-" + uuid.uuid4().hex incarnation = "ng-native-test-" + uuid.uuid4().hex
statefile = ROOT / ".pi/state/goal" / f"goal-state.{incarnation}.json" statefile = ROOT / ".pi/state/goal" / f"goal-state.{incarnation}.json"
statefile.parent.mkdir(parents=True, exist_ok=True) statefile.parent.mkdir(parents=True, exist_ok=True)
@@ -70,7 +70,10 @@ with tempfile.TemporaryDirectory(prefix="ng-native-pi-") as home:
if status == "none": if status == "none":
state.update(text="", lastOutcome=dict(text=TEXT, status="complete", evidence="native fixture", at="2026-09-06T00:00:00.000Z")) state.update(text="", lastOutcome=dict(text=TEXT, status="complete", evidence="native fixture", at="2026-09-06T00:00:00.000Z"))
if status == "active": if status == "active":
state.update(waitTimeoutSeconds=3600, waitWakeUsed=False, activeWait=dict(owner="native fixture", nextCheck="manual acceptance", deadlineAt=int(time.time() * 1000) + 3600000, wakeSent=False)) state.update(activeWait=dict(owner="native fixture", nextCheck="manual acceptance"))
if timed:
state.update(waitTimeoutSeconds=3600, waitWakeUsed=False)
state["activeWait"].update(deadlineAt=int(time.time() * 1000) + 3600000, wakeSent=False)
statefile.write_text(json.dumps(state)) statefile.write_text(json.dumps(state))
tty_env = {**env, "MOSAIC_LAUNCH_INCARNATION": incarnation} tty_env = {**env, "MOSAIC_LAUNCH_INCARNATION": incarnation}
tty_env.pop("NO_COLOR", None) tty_env.pop("NO_COLOR", None)
@@ -98,7 +101,10 @@ with tempfile.TemporaryDirectory(prefix="ng-native-pi-") as home:
Path("/tmp/ng-goal-native-entered.log").write_bytes(entered) Path("/tmp/ng-goal-native-entered.log").write_bytes(entered)
os.write(master, b"\x1b[13u") os.write(master, b"\x1b[13u")
receive(master, "END-OF-GOAL") receive(master, "END-OF-GOAL")
print(f"PASS native {label}, width={width}, NO_COLOR={no_color}: footer + /goal + Alt+G", flush=True) if status == "active":
current = json.loads(statefile.read_text())
assert current == state, "waiting startup/recall must not inject checks or mutate the fixture"
print(f"PASS native {label}, width={width}, NO_COLOR={no_color}, timed={timed}: footer + /goal + Alt+G; zero wait checks", flush=True)
finally: finally:
os.killpg(child.pid, signal.SIGTERM) os.killpg(child.pid, signal.SIGTERM)
child.wait(timeout=10) child.wait(timeout=10)
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""#58 local fleet repair. Default preflight; explicit pinned apply or rollback.
Only two source directories and the Resume-hotfix launcher may change. Settings,
credentials, state, sessions and enforcement extensions are never written.
Linux renameat2 exchanges keep originals at the private backup paths, including
when interrupted. A receipt can recover a partially applied transaction.
"""
import argparse
from contextlib import contextmanager
import ctypes
import fcntl
import hashlib
import json
import os
from pathlib import Path
import stat
import uuid
SHARED = '.pi/extensions/goal'
ALIASES = ('fleet/extensions/goal', 'fleet/agents/velma/.pi/extensions/goal')
LAUNCHER = 'fleet/bin/launch-seat.sh'
DEPENDENCIES = ('.pi/extensions/mosaic-core', 'fleet/extensions/mosaic-core',
'fleet/agents/velma/.pi/extensions/mosaic-core',
'fleet/extensions/wrapper-guard/index.ts', 'tools/unslop-hook/extension.ts')
HOTFIX_SHA = 'dd9e5ece5f1a86cc286027668560198bb7a44d2ab891f7b1ab4f89d7379ca0e6'
ORIGINAL_SHA = '9352feed0acf9d449c26c0556ba00aba1c65ded43d376930a39ff6b5cee21986'
def require(condition, message):
if not condition:
raise RuntimeError(message)
def sha(data):
return hashlib.sha256(data).hexdigest()
def snapshot(path):
"""Ordinary source files only. Do not call this on goal-state directories."""
require(path.resolve() == path, f'non-canonical source path: {path}')
result = {}
paths = [path] + (sorted(path.rglob('*')) if path.is_dir() else [])
for p in paths:
s = p.lstat()
kind = 'dir' if stat.S_ISDIR(s.st_mode) else 'file' if stat.S_ISREG(s.st_mode) else None
require(kind is not None, f'symlink/special source file refused: {p}')
require(s.st_uid == os.getuid(), f'foreign-owned source: {p}')
result[str(p.relative_to(path))] = {'type': kind, 'mode': stat.S_IMODE(s.st_mode)}
if kind == 'file':
result[str(p.relative_to(path))]['sha256'] = sha(p.read_bytes())
return result
def inventory(root):
"""Pin configuration without copying its contents or reading auth/state."""
paths = []
for group in ('agents', 'roles'):
paths += list((root / 'fleet' / group).glob('*/.pi/agent/settings.json'))
paths += [root / 'fleet/templates/pi-settings.json.template']
result = {}
for p in sorted(paths):
data = p.read_bytes()
entry = {'sha256': sha(data), 'resolved': str(p.resolve()),
'link': os.readlink(p) if p.is_symlink() else None}
if p.name == 'settings.json':
settings = json.loads(data)
entry['extensions'] = settings.get('extensions', [])
require(not settings.get('packages'), f'unreviewed package source: {p}')
result[str(p.relative_to(root))] = entry
# Environment files may carry credentials. Pin filesystem identity/change
# metadata only; never read or copy their contents for this source repair.
for p in sorted((root / 'fleet/agents').glob('*/launch.env')):
s = p.stat()
result[str(p.relative_to(root))] = {
'resolved': str(p.resolve()), 'link': os.readlink(p) if p.is_symlink() else None,
'device': s.st_dev, 'inode': s.st_ino, 'size': s.st_size,
'mtime_ns': s.st_mtime_ns, 'ctime_ns': s.st_ctime_ns,
}
return result
def restored_launcher(data):
require(sha(data) == HOTFIX_SHA, 'launcher is not the reviewed Resume hotfix')
start = data.index(b'# Issue #57: Resume uses the operator-selected shared NG goal')
end = data.index(b'for f in "${PROMPT_FILES[@]}"', start)
result = data[:start] + data[end:]
require(sha(result) == ORIGINAL_SHA, 'launcher restoration differs from original')
return result
def prepare(root):
require(root.is_dir() and root.resolve() == root, 'root must be a canonical directory')
after = restored_launcher((root / LAUNCHER).read_bytes())
before = {p: snapshot(root / p) for p in (*ALIASES, LAUNCHER)}
launcher_after = {'.': {**before[LAUNCHER]['.'], 'sha256': sha(after)}}
return {'version': 1, 'root': str(root), 'shared': snapshot(root / SHARED),
'dependencies': {p: snapshot(root / p) for p in DEPENDENCIES},
'before': before, 'launcherAfter': launcher_after, 'inventory': inventory(root)}
def validate_plan(plan):
require(plan['version'] == 1, 'unsupported plan')
root = Path(plan['root'])
require(root.is_absolute() and root.resolve() == root, 'invalid root')
require(set(plan['before']) == set((*ALIASES, LAUNCHER)), 'invalid mutation scope')
require(plan['before'][LAUNCHER]['.']['sha256'] == HOTFIX_SHA, 'unreviewed launcher pin')
require(plan['launcherAfter']['.']['sha256'] == ORIGINAL_SHA, 'unreviewed restoration pin')
return root
def verify_common(plan):
root = validate_plan(plan)
require(snapshot(root / SHARED) == plan['shared'], 'shared source drift')
require(set(plan['dependencies']) == set(DEPENDENCIES), 'invalid dependency scope')
for p in DEPENDENCIES:
require(snapshot(root / p) == plan['dependencies'][p], f'dependency drift: {p}')
require(inventory(root) == plan['inventory'], 'configuration inventory drift')
return root
def verify_before(plan):
root = verify_common(plan)
for p in (*ALIASES, LAUNCHER):
require(snapshot(root / p) == plan['before'][p], f'before drift: {p}')
def is_alias(path, shared):
return path.is_symlink() and os.readlink(path) == str(shared) and path.resolve() == shared
def verify_after(plan):
root = verify_common(plan)
for p in ALIASES:
require(is_alias(root / p, root / SHARED), f'alias drift: {p}')
require(snapshot(root / LAUNCHER) == plan['launcherAfter'], 'launcher after drift')
def exchange(a, b):
fn = ctypes.CDLL(None, use_errno=True).renameat2
fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
fn.restype = ctypes.c_int
if fn(-100, os.fsencode(a), -100, os.fsencode(b), 2):
error = ctypes.get_errno()
raise OSError(error, os.strerror(error))
def write_once(path, data):
with path.open('xb') as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
def stage_path(backup, relative):
return backup / ('launcher' if relative == LAUNCHER else 'legacy' if relative == ALIASES[0] else 'velma')
def recover(plan, backup):
"""Validate every pair before reverting any. Handles partial exchanges."""
root = verify_common(plan)
require(backup.parent == root / '.pi/goal-backups' and backup.resolve() == backup,
'backup outside approved directory')
swapped = []
for p in (*ALIASES, LAUNCHER):
target, stage = root / p, stage_path(backup, p)
if p in ALIASES:
target_after = is_alias(target, root / SHARED)
stage_after = is_alias(stage, root / SHARED)
else:
target_after = snapshot(target) == plan['launcherAfter']
stage_after = snapshot(stage) == plan['launcherAfter']
if target_after:
require(snapshot(stage) == plan['before'][p], f'rollback backup drift: {p}')
swapped.append(p)
else:
require(snapshot(target) == plan['before'][p] and stage_after, f'rollback pair drift: {p}')
for p in reversed(swapped):
exchange(root / p, stage_path(backup, p))
verify_before(plan)
write_once(backup / ('rollback-' + uuid.uuid4().hex + '.json'),
json.dumps({'status': 'rolled back', 'restored': swapped}).encode())
def apply(plan, raw):
verify_before(plan)
root = Path(plan['root'])
backup_root = root / '.pi/goal-backups'
backup_root.mkdir(mode=0o700, parents=True, exist_ok=True)
require(backup_root.resolve() == backup_root, 'backup directory alias refused')
backup = backup_root / ('goal58-' + uuid.uuid4().hex)
backup.mkdir(mode=0o700)
for p in ALIASES:
stage_path(backup, p).symlink_to(root / SHARED, target_is_directory=True)
stage = stage_path(backup, LAUNCHER)
write_once(stage, restored_launcher((root / LAUNCHER).read_bytes()))
os.chmod(stage, plan['before'][LAUNCHER]['.']['mode'])
for p in (*ALIASES, LAUNCHER):
require((root / p).lstat().st_dev == stage_path(backup, p).lstat().st_dev, 'cross-device exchange refused')
write_once(backup / 'planned.json', raw)
print(f'Recovery receipt: {backup}', flush=True)
try:
verify_before(plan)
for p in (*ALIASES, LAUNCHER):
require(snapshot(root / p) == plan['before'][p], f'concurrent drift: {p}')
exchange(root / p, stage_path(backup, p))
verify_after(plan)
for p in (*ALIASES, LAUNCHER):
require(snapshot(stage_path(backup, p)) == plan['before'][p], f'backup mismatch: {p}')
write_once(backup / 'deployed.json', json.dumps({'status': 'deployed', 'planSha256': sha(raw)}).encode())
except BaseException:
recover(plan, backup)
raise
return backup
@contextmanager
def deployment_lock(plan):
root = verify_common(plan)
directory = root / '.pi/goal-backups'
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
require(directory.resolve() == directory, 'backup directory alias refused')
fd = os.open(directory / 'goal58.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
yield
finally:
os.close(fd)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('mode', nargs='?', default='preflight', choices=['prepare', 'preflight', 'apply', 'verify', 'rollback'])
parser.add_argument('--root', type=Path)
parser.add_argument('--plan', type=Path, required=True)
parser.add_argument('--sha256')
parser.add_argument('--backup', type=Path)
args = parser.parse_args()
if args.mode == 'prepare':
require(args.root is not None, '--root required')
plan = prepare(args.root)
raw = (json.dumps(plan, indent=2, sort_keys=True) + '\n').encode()
write_once(args.plan, raw)
print('Prepared plan SHA256:', sha(raw))
return
raw = args.plan.read_bytes()
require(sha(raw) == args.sha256, 'reviewed plan SHA256 required')
plan = json.loads(raw)
if args.mode == 'preflight':
verify_before(plan)
elif args.mode == 'apply':
with deployment_lock(plan):
print('Deployed:', apply(plan, raw))
elif args.mode == 'verify':
verify_after(plan)
else:
require(args.backup is not None, '--backup required')
require((args.backup / 'planned.json').read_bytes() == raw, 'backup receipt mismatch')
with deployment_lock(plan):
recover(plan, args.backup)
print('PASS', args.mode)
if __name__ == '__main__':
main()