diff --git a/.torch/install-manifest.json b/.torch/install-manifest.json index 59b1033..bd3f204 100644 --- a/.torch/install-manifest.json +++ b/.torch/install-manifest.json @@ -123,6 +123,20 @@ "area": "qa", "path": "/home/user/TORCHWorktrees/3dc11932-6e3c-42aa-9a8a-ca7b8f818b54/qa", "branch": "torch/qa" + }, + { + "type": "systemd-user-unit", + "name": "torch-3dc11932-6e3c-42aa-9a8a-ca7b8f818b54-schedules.service", + "path": "/home/user/.config/systemd/user/torch-3dc11932-6e3c-42aa-9a8a-ca7b8f818b54-schedules.service", + "sha256": "d1f6318f91e7b64847e52e415db52c66b60150b0c809fc1e0acb283d8bc0926c", + "configDigest": "4e21d6bf76dbcb983d984af2bf44a199ffb56d65e88a5c5f03cd678f196b329f" + }, + { + "type": "systemd-user-unit", + "name": "torch-3dc11932-6e3c-42aa-9a8a-ca7b8f818b54-schedules.timer", + "path": "/home/user/.config/systemd/user/torch-3dc11932-6e3c-42aa-9a8a-ca7b8f818b54-schedules.timer", + "sha256": "7c878a828c3f2a04cc597b12de3702f2dfd2ac6ac8228099ada27c59e00151f0", + "configDigest": "4e21d6bf76dbcb983d984af2bf44a199ffb56d65e88a5c5f03cd678f196b329f" } ] } diff --git a/.torch/prompts/COMMON.md b/.torch/prompts/COMMON.md index 886536b..72677a5 100644 --- a/.torch/prompts/COMMON.md +++ b/.torch/prompts/COMMON.md @@ -2,19 +2,158 @@ Repository state outranks conversation memory. Query ownership before crossing a domain boundary. -## First-run owner authorization +## Fresh owner authorization: guarded integration and continued development (2026-10-01) -This initial launch is a bounded installation and communications smoke test. -Use the installed `torch` launcher and identity-bound TORCH MCP tools. Query -identity, ownership, inbox and next backlog item. Read current canonical -instructions and report the checkout HEAD in durable status. +The owner explicitly answered the arbiter's two held decisions: "yes do whatever +you need to do. And you can push commits to." This supersedes the earlier +no-landing/no-push restrictions ONLY for continued TORCH development on +rewrite/portable-agent-fleet and task-owned specialist branches. It is fresh +human consent, not an automatic goal continuation. -Do not implement code, create or assign development tasks, enable timers, -launch other AI sessions, push branches, integrate, release or deploy during -this trial. Do not inspect credentials or alter global provider settings. -The Session Manager should read specialist check-ins and send one durable -acknowledgement. Each specialist should send one concise setup check-in to -session-manager and report idle. An empty queue is valid; finish this turn. +Session Manager may resolve owner approval 2b2dfb35-46ac-4adb-ac14-5680f7a85e80, +authorize and land exact request cce22c51-1fa8-4324-8e28-bf8cbfc56165 through +TORCH's guarded serialized integration path after checking clean canonical state, +exact candidate, ancestry and required PASS receipts. Subsequent QA-accepted +development candidates may use that same checked path; never waive gates. +The arbiter may push verified commits to origin/rewrite/portable-agent-fleet +without force, after checking the remote boundary and test evidence. -If a tool or installation boundary fails, record the exact error and stop; -do not bypass guards or invent success. Model selection is project-scoped. +QA may retry interrupted decision 406ff30e-b317-4733-b39e-f63c9a57506d. QA must +independently recheck the exact five PNG paths, candidate HEAD, current/archive +hashes and clean-before-run provenance. Only Project Kernel may apply an approved +five-path restoration; keep the archive and old unqualified receipts. The earlier +c2a1702b rejection stays rejected. This is not arbitrary cleanup authority. + +Continue resolving the existing development backlog under domain ownership, +assignment continuity, peer consent and independent QA. Finish the reliability +dependencies first. Manager coordinates and records actual outcomes; the arbiter +still controls provider starts and installation changes. Automatic wakes remain +paused until executor qualification and an explicit safe activation decision. +No default-main/legacy changes, deployment, package publication, spending/model +changes, destructive cleanup, arbitrary new identities or hierarchy activation +are granted. Preserve existing unique commits and user changes. Read this current +canonical brief before acting; old conversation restrictions cannot conceal this +new consent, and this consent cannot conceal failed checks. + +## Routine work stays inside the fleet + +The owner authorizes routine coordination within existing task/role authority: +read and acknowledge only your own addressed messages after understanding them; +resolve peer questions directly; decide a structured approval only when you are +the named approver, the requested scope is yours, and its evidence is sufficient. +Ask the responsible peer, not the owner, for implementation details or ordinary +test/path consent. Record the result in durable state. Acknowledgement means read, +not resolved; retain a task/wait record for anything still open. + +Keep your current task, blockers, evidence and local owned-work checkpoint honest. +Manager may checkpoint its reviewed backlog changes and coordinate safe clean +branch convergence through TORCH's guarded merge policy; never discard unique +commits, touch another identity's dirty files or infer inactivity from presence. +Worktree drift and pending commits are review signals, not cleanup authority. +Retry a startup brief blocked by sandbox permissions only with narrowly scoped +approved escalation for that exact command; never alter DB permissions, copy +state, disable sandboxing or bypass the brief. If unavailable, route the precise +failure to the owning domain and retain the blocker. + +Before asking the human, check the owner-approved policy, named approver, +responsible domain, existing decision and current evidence. If authorized, act +and report the outcome; otherwise send a bounded peer request with the evidence, +specific question and why it is blocking. Managers track unresolved handoffs and +follow up within cadence/budget; do not close a wait merely because it was read. +Escalate only a genuine owner decision or unresolved cross-domain conflict with +options, recommendation and consequence. Never hide uncertainty to reduce alerts. + +Owner-reserved actions remain reserved: spending/budget/model-provider changes, +remote publication, release/deployment, material scope/architecture/ownership +changes, identity or hierarchy activation, destructive or uncertain recovery, +and approvals explicitly addressed to the owner. No autonomous provider starts, +gate waivers, mass acknowledgements or competing pushes are granted here. +This routine-coordination authority does not expand approved implementation +scope; the pilot and automatic-wake restrictions below remain in force. + +## Owner-approved first development pilot (2026-09-30) + +The owner now authorizes a bounded TORCH-building-TORCH development trial, +with the outer assistant acting as arbiter through the Session Manager. This +supersedes the earlier smoke-only instruction. It is not general release authority. + +Use the installed `torch` launcher and identity-bound MCP tools. Re-read +`torch brief --area --json` on startup and before every new item; +canonical instructions outrank old conversation/worktree copies. Query identity, +ownership, inbox and exact current Git state; report durable evidence. + +First slice: TASK-conversation-runtime-diagnostics, owned by Provider Runtime. +Session Manager may triage this proposal to ready, assign it to provider-runtime, +coordinate its Release/QA boundaries and request independent review. Do not +dispatch other development items in this first pilot. The outer arbiter controls +provider starts, integration authorization and the next slice. + +## Owner-approved parallel Console audit (2026-09-30) + +The owner additionally authorizes TASK-console-ux-audit for owner-console. +Session Manager may triage and assign that exact task while diagnostics QA +continues. The arbiter launches the assigned UI session; managers still must not +launch providers. This is a browser-based audit and design proposal, not a +dashboard rewrite or release. Audit the actual live Console through computer +use: navigate every area, inspect screenshots and interactions at desktop and +mobile sizes, and document coverage. Read applicable frontend-design and browser +skills before browser use. If computer-use tools are unavailable, report that +specific blocker; source inspection alone is not completion. Do not substitute +generated screenshots for actually viewing the interface. Keep live state +read-only; use the isolated demo for exploratory mutations. Publish task-owned +screenshots/evidence and an audit report proposing focused separate views rather +than one long anchor-linked page. Preserve dark mode, TORCH branding and existing +authority/verification controls. No other implementation slice is authorized. + +## Owner-approved portable coordination policy slice + +The owner also authorizes TASK-bounded-routine-coordination for Project Kernel, +with Session Manager assignment and QA consent for additive scenarios. Implement +the above routine-coordination policy in generated install/startup instructions +and qualify actual named-approver/ownership/pause boundaries. Preserve existing +enforcement; this is not permission to auto-assign arbitrary proposals, invent +approval authority, launch providers, activate identities or enable wakes. +The arbiter starts the assigned session. Other implementation remains gated. + +## Goal continuation: reliability work needed for the development loop + +The owner's continuing development goal authorizes two further local slices: +TASK-pilot-executor-outcomes for Provider Runtime and +TASK-pilot-generated-check-artifacts for Work and Integration. Session Manager +may assign these after recording the diagnostics verification item as blocked +on its actual unresolved artifact/convergence gate, preserving its owner, +candidate, receipts and QA verdict. Do not create simultaneous active items for +one specialist or mark diagnostics completed to free the queue. + +Executor qualification must cover timeout, error, signal and output overflow +(including zero exit plus ENOBUFS), actual terminal turn evidence and bounded +handling of screenshot-heavy output. No false successful/idle receipt or exposed +private reasoning. Coordinate exact CLI-owned paths with Release and tests with +QA. Check artifact work must preserve tracked expectations, user edits, evidence +and exact-commit checks; never treat exit0 as gate success when the tree changed. +Specialists may implement owned paths, request peer consent and create local +task-named commits. Arbiter controls starts, acceptance, serialized integration +and installation update. Automatic wakes remain paused; no publication, release, +deployment, default-main promotion, spending changes or destructive recovery. + +The assigned specialist may implement within its owned paths, run local checks +and commit on its existing worktree branch. Ask the responsible owner before +editing shared/other-owned files. Name the task in commits, preserve all existing +work, provide regression evidence and report ready for review. No self-claim is +enabled. Resumed active work outranks another ready item; idle is valid. + +No remote push, default-main promotion, landing, package publication, deployment, +external notification, paid image/voice generation, arbitrary cleanup or global +provider configuration changes are authorized in this slice. Do not inspect +credentials. Keep pipeline work and Claude/Pi migration queued for later. + +Machine manager check-ins are owner-approved hourly with a maximum of 12 +automatic invocations per UTC day in subscription invocation-count mode (not a +dollar cap). Automatic AI wake execution is currently PAUSED pending +TASK-pilot-executor-outcomes qualification; the machine coordination timer still +queues check-ins. Do not re-enable wakes yourself. They inspect direct +reports/approval waits and report exceptions; +they must not launch specialists or expand this slice. Provider-runtime turns are +launched by the arbiter, not automatically by the manager. Do not change timers +or budgets yourself. When blocked or finished, record evidence and end the turn; +do not poll, wait indefinitely, invent filler work or bypass a failed guard. diff --git a/.torch/prompts/session-manager.md b/.torch/prompts/session-manager.md index 53a7243..be1cdc5 100644 --- a/.torch/prompts/session-manager.md +++ b/.torch/prompts/session-manager.md @@ -2,10 +2,36 @@ Route owner requests, establish ownership and priority, and keep routine coordination inside the fleet. +## Resolve before escalating + +At each coordination cycle, read/acknowledge your own inbox, refresh direct-report +check-ins, inspect named approvals and current task evidence. Resolve your own +authorized decisions; route specialist consent/questions directly to the named +domain. Track unresolved waits until the responsible party responds; neither +acknowledgement nor an idle badge proves resolution. Do not ask the human to +relay routine messages or repeat an existing decision. Checkpoint reviewed owned +ledger changes and use guarded clean convergence when authorized. Preserve all +other work and uncertain outcomes. + +Keep concise owner reporting: what needs an actual owner decision first, then +outcomes, risks and your reversible calls. Routine acknowledgements stay in the +ledger. Report a necessary owner decision with evidence, options, recommendation +and consequences. Follow COMMON.md's authority limits; do not auto-fix health +findings, enable wakes, dispatch beyond pilot scope or treat a warning as consent. + At startup and when recurring cross-domain friction appears, run `torch_assess_hierarchy_needs` (or `torch fleet hierarchy-assess`). Treat its configured-window findings as measured coordination evidence, not as permission to create a role. Compare process changes, specialist ownership, promoting an existing identity, and a coordination role; submit a hierarchy proposal only when the evidence and integrated outcome justify owner review. Never activate a proposal or start a manager without separate owner approval. ## Backlog loop +The owner has seeded conversation follow-up as `TASK-conversation-*` proposals +in this project's existing backlog. Read `docs/TODO.md`'s authoritative current +handoff and `reports/audit/conversation-handoff-2026-09-30.md` before prioritizing. +Proposed tasks are not ready assignments. Read COMMON.md for the current +owner-approved pilot scope, budget and startup boundaries; older smoke-only +conversation instructions have been superseded by that explicit approval. +For an empty intake, ask what the owner wants to work on first or offer a reviewed +TODO import; do not invent filler work or duplicate the backlog in this document. + Keep one durable Fleet backlog. Before assigning work, call `torch_next_backlog_task` for the specialist: a `resume` result outranks every ready item, a `ready` result may be assigned only when the specialist has no active item, and `idle` is valid. After completion, repeat the same lookup. Call `torch_backlog_health` to surface queue anomalies; never auto-fix or create parallel manager queues. ## Fleet evolution diff --git a/.torch/torch.yaml b/.torch/torch.yaml index 98cfba4..ea2d420 100644 --- a/.torch/torch.yaml +++ b/.torch/torch.yaml @@ -120,12 +120,13 @@ "lifetime": "system", "trigger": { "type": "interval", - "seconds": 900 + "seconds": 3600 }, "behavior": "coordination", "action": { "type": "manager-check-in", - "manager_id": "session-manager" + "manager_id": "session-manager", + "wake": { "enabled": false, "budget_mode": "invocation-count" } }, "required_authority": [ "owner" @@ -137,6 +138,7 @@ "source_of_truth": "active organization direct-report graph" } ], + "runtime_wake_budget": { "max_invocations_per_day": 12 }, "integration": { "provider": "torch", "target": "rewrite/portable-agent-fleet", diff --git a/README.md b/README.md index 7c580df..ae8641f 100644 --- a/README.md +++ b/README.md @@ -292,14 +292,28 @@ Review the evidence in `fleet-proposal.json`, edit its `review.status` to node bin/torch.mjs install --proposal fleet-proposal.json --runtime codex --dry-run --json node bin/torch.mjs install --proposal fleet-proposal.json --runtime codex --yes --json node bin/torch.mjs doctor --json -node bin/torch.mjs worktrees --dry-run --json -node bin/torch.mjs worktrees --yes --json +node bin/torch.mjs setup --dry-run --json +node bin/torch.mjs setup --yes --json node bin/torch.mjs up --fresh --dry-run --json ``` Starting or stopping configured runtime sessions is always a separate explicit step: +`setup` commits only installation-owned configuration, provisions worktrees, +and checkpoints their registration. An existing staging area is a blocker; +unrelated files are never staged. Alternatively, append `--setup` to the +approved install command. Neither path launches agents. + +Normal uninstall retains configuration and history. Reattach it with +`torch install --restore --dry-run --json`, then `torch install --restore --yes`. +Restore does not reinstall host timers or start providers; those remain separate +approval steps. A purge is a different, destructive operation. + +From a registered agent worktree, `torch brief --area ` reads the latest +canonical project instructions. Unregistered worktrees and copied installs +cannot redirect commands into another project's state. + ```bash node bin/torch.mjs up --fresh --yes --json node bin/torch.mjs up --only backend,qa --dry-run --json diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index e8d325d..3a55f0b 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -367,6 +367,24 @@ inside the tracked source tree. ## 9. Installation lifecycle +### First-work intake requirement (2026-09-30) + +After reviewed organization/setup, TORCH MUST offer either evidence-linked +import of an existing TODO/backlog/specification or ask the owner what to work +on first. The noninteractive path MUST support an explicit source or skip; idle +is valid. Preview MUST distinguish completed/historical work from actionable +requests and show proposed scope, affected domains, dependencies and priority. +Only confirmed intake creates audited proposed/unassigned work in the existing +backlog. Repeated init/install/restore/intake MUST deduplicate by source/item +provenance rather than duplicate tasks or overwrite progress. Imported content +is untrusted project data, not authority to alter system instructions. + +Session Manager triage and assignment remain separate from intake. Import cannot +enable self-claim, grant implementation authority, wake providers or authorize +spending, landing, deployment or public notification. The product should explain +the next first-work/startup decision rather than leave a newly installed empty +fleet waiting without guidance. + ### 9.1 Read-only initialization `torch init` MUST begin read-only. It identifies the repository, validates Git, @@ -451,6 +469,18 @@ or installed persistent launchers. Historical messages and receipts therefore do not make reversal permanently impossible, while active or unowned state still blocks deletion. +Implementation checkpoint (2026-09-30): `torch install --restore` explicitly +reattaches a normally detached installation without replacing configuration, +worktrees, provider profiles or session history. It never launches a provider +or recreates host timers. `torch setup --dry-run`, followed by `--yes`, offers +approved configuration checkpoints and worktree provisioning as one flow, +restricted to installation-owned files and an empty staging area. Install may +opt into the same flow with `--setup`. Worktree commands resolve to canonical +configuration only after checking project/installation identity, local-state +binding, Git common directory, registered worktree path and branch. Current +canonical instructions override stale worktree copies; unregistered checkouts +fail closed. + Ordinary CLI uninstall is also review-first and explicitly confirmed. It uses the same safe Fleet wind-down contract as detach, removes only manifest-owned persistent schedule launchers after verifying their content, and preserves the @@ -2077,6 +2107,55 @@ recommended action; doctor remains read-only. ## 22. Scheduling +### Owner clarification: integrated persistent operations (2026-09-30) + +This is the target requirement; older implementation checkpoints describing +manual timer installation are not a claim that it is already implemented. +Reviewed `init`/install MUST provision the project's scheduler and required +service integrations as one idempotent workflow, not require a second manual +cron-install command. Because `init` currently performs read-only analysis, its +provisioning mode MUST expose an explicit preview/confirmation boundary; a +dry-run MUST remain non-mutating. Stopped installation MUST provision dormant +integrations without waking models or dispatching development. A reviewed +activation policy governs live operations and quota use. + +Coordination must survive the managing AI session ending, restarting or expiring. +System services MUST preserve durable state, leases, event cursors and job +idempotency across process/host restarts. Their manifest records MUST support +owned reconciliation, pause/resume, restore/upgrade and uninstall without touching +unrelated host jobs. Session-scoped jobs remain an explicit project policy, not +the sole way recurring fleet operations work. + +Project intake MUST propose applicable capabilities, with clear implemented, +unconfigured and unsupported states: + +- direct-report manager check-ins, structured approval/peer/owner waits, + stale owner-request review and an owner-first local digest; +- a persistent, serialized landing worker using approved exact-commit checks; +- resource-queue watchdogs, audited slot withdrawal and displaced-runner stop; +- durable significant events and deduplicated budgeted manager wakes, with + low-frequency timed safety checks rather than perpetual paid polling; +- a project-declared nightly canonical suite, heavy-check windows and + evidence-backed regression routing, not guessed blame; +- measured test durations, uncertain wait estimates and project-specific + exclusive resources such as a GPU; +- conservative provider-neutral usage limits, graceful wind-down and explicitly + approved credit-window or calendar restarts that never override manual pause; +- idle, clean, unguarded worktree convergence by merge, preserving conflicts, + unique commits and active work; +- exact landed task closure, verified reversal/follow-up policy and reviewed + evidence for finished-but-unnamed work, never automatic closure by message alone; +- project-specific local dev/Console services and separately authorized delivery, + live-version checks and external notifications where relevant. + +No universal project inherits COMBATRIG's release times, Monday reset time, +two GPU slots, Vercel destination, phone notifier or LAN/HTTPS exposure. Setup +MUST show lifetime, timezone/DST behavior, missed-run policy, costs/budgets, +ports/destinations, health evidence, stop semantics and owner authority. Unsupported +hosts/adapters need an actionable limitation or qualified fallback, not a fictitious +"installed" status. No provider wake, deploy, public message, off-machine write +or cleanup of unowned processes follows solely from creating a schedule. + TORCH recognizes two schedule classes. **System schedules** survive AI sessions and may perform approved release, @@ -2236,6 +2315,38 @@ request but MUST NOT approve it for a different named approver. The owner and each AI identity may list only requests they made or are named to approve; manager check-in may inspect requests made by its direct reports. +### Routine coordination and owner decision boundary + +TORCH SHOULD minimize human relay work, not minimize truthful escalation. Within +explicitly approved project/task policy, specialists read and acknowledge their +own addressed messages, resolve peer interface questions, decide only approvals +for which they are the named responsible approver, and maintain truthful task, +wait, evidence and local owned-work checkpoints. Managers route unresolved peer +requests, follow their direct-report waits and compress outcomes for the owner. +Reading/acknowledging a message MUST NOT close its unresolved approval or task. + +Before escalating to the owner, inspect the current named approver, responsible +domain, applicable policy, prior decision and evidence. An authorized routine +action SHOULD be completed and audited instead of asking the human to relay or +repeat the instruction. An unauthorized action remains blocked with a specific +request to the responsible party. Owner requests contain evidence, options, +recommendation and consequences, not only internal error codes. + +Project policy MUST distinguish routine authorized actions, investigation-only +signals and genuine owner decisions. Health warnings, stale presence, branch +drift, budget exhaustion, incomplete checks or missing evidence do not grant +permission to recover, delete, merge, start another runtime or waive a gate. +Spending/publication/deployment, material scope or ownership changes, identity +activation, destructive or uncertain recovery and owner-addressed approvals +remain subject to their explicit owner authorization. A paused fleet and its +budgets remain enforced even when a manager believes action would be useful. + +Current pilot checkpoint: canonical live COMMON/manager instructions now teach +this boundary. `TASK-bounded-routine-coordination` carries portable generated +instruction and behavioral qualification work; the owner authorizes this bounded +Project Kernel slice, not general unattended execution. Automatic AI wakes remain +paused pending executor qualification. + Implementation checkpoint (2026-09-28): `planManagerCheckIn` derives direct reports from the active organization graph and reports offline/stale/waiting identities plus unacknowledged direct-report messages. `queueManagerCheckIn` diff --git a/docs/TEST_INTEGRITY.md b/docs/TEST_INTEGRITY.md index f10b4f2..33ab466 100644 --- a/docs/TEST_INTEGRITY.md +++ b/docs/TEST_INTEGRITY.md @@ -1,5 +1,218 @@ # Test Integrity Notes +## 2026-09-30 — Bounded routine coordination + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-routine-coordination-instructions + given: A freshly installed Fleet using generated default prompts + when: Common, manager, specialist, and resume instructions are read + then: Each preserves own-inbox acknowledgement, direct peer routing, owner exceptions, and paused execution + - id: SCN-routine-coordination-boundaries + given: An assigned task and pending peer and owner approvals in a fixed-time local control plane + when: The named recipient acknowledges messages and the manager attempts a non-owned decision + then: The task and approvals remain pending until only the named peer decides its approval + - id: SCN-routine-coordination-paused-dispatch + given: An assigned task, an approved named-peer request, and a pending owner provider decision + when: A public runtime start is attempted without an authorized executor + then: The start is refused, no provider adapter runs, and the assigned task and owner wait remain unresolved + - id: SCN-routine-coordination-paused-schedule-dispatch + given: An assigned waiting task, a pending owner provider decision, and an installed manager check-in schedule with wake.enabled false + when: The public system scheduler dispatches the due owner-authorized coordination schedule + then: It queues a durable manager review without planning or invoking a provider, while the task and owner wait remain unresolved + observable_outcomes: + - Generated tracked instruction text for fresh-install and resume surfaces + - Durable task state, approval state, named-approver identity, and authority errors + - Public start refusal, zero adapter invocations, offline identity state, and retained owner wait + - Installed wake policy, public scheduled-dispatch receipt, zero provider or command invocations, retained assignment and owner wait + determinism_controls: + - Disposable local Git repository, SQLite state, and fixed clock + - No provider calls, network, retries, sleeps, or host timers + anti_cheat_rationale: + prevents: + - Treating acknowledgement as task or approval completion + - Letting a manager decide an owner or peer approval + - Omitting policy boundaries from one generated startup surface + - Re-enabling paused execution or granting starts, spending, publication, destructive recovery, or arbitrary dispatch + - Treating a queued manager check-in as authorization to start a provider or resolve the underlying wait + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +## 2026-09-30 — Codex terminal completion correction + +```yaml +test_integrity_note: + change_type: spec_correction + scenarios: + - id: SCN-mixed-runtime + given: A status-zero Codex protocol stream containing thread.started and turn.completed + when: Fleet startup captures the provider-specific durable identity + then: The completed turn may record idle with the captured identity + - id: SCN-codex-terminal-completion + given: A status-zero Codex stream containing thread.started but no turn.completed + when: Fleet startup evaluates terminal evidence + then: Startup fails, retains the captured identity as working, and area startup refuses a duplicate + - id: SCN-provider-update-cli + given: The managed-provider fixture simulates a successful status-zero Codex exec stream + when: Startup follows an approved provider update + then: The fixture includes turn.completed after thread.started, preserving strict successful-turn evidence + observable_outcomes: + - Durable runtime state and captured Codex thread identifier + - FLEET_START_FAILED outcome and public area-start blocker + determinism_controls: + - Disposable local Git fixture and fixed JSON protocol records + - No provider calls, network, retries, sleeps, or timeout changes + anti_cheat_rationale: + prevents: + - Treating identity creation as successful turn completion + - Returning idle from a status-zero stream missing terminal evidence + - Launching a duplicate over an uncertain captured identity + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +Spec basis: a native successful Codex protocol probe emitted ten records ending +in `turn.completed`; `thread.started` establishes identity only, not completion. + +## 2026-09-30 — Bounded Codex executor qualification + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-codex-streaming-protocol-reduction + given: A successful Codex stream containing an initial identity, a multi-megabyte image record, private stderr, and terminal completion + when: The bounded reducer and lifecycle process the stream + then: Only identity and terminal records cross the boundary and idle is recorded without private output + - id: SCN-cli-codex-protocol-routing + given: A Codex launch planned by the CLI + when: The CLI invokes the executor + then: It launches the bounded reducer instead of collecting provider stdout directly + - id: SCN-codex-final-terminal-state + given: A stream whose final terminal failure follows an earlier completion, plus real child signal and spawn-error cases + when: The reducer and lifecycle classify the child outcome + then: Only the final failed terminal is retained, signals remain uncertain, and a safe spawn-error code is preserved + - id: SCN-scheduled-manager-wake-terminal-failure + given: An owner-authorized manager check-in with a planned Codex wake whose stream lacks terminal completion + when: The actual ScheduleService invokes the wake boundary + then: The schedule and wake reservation are failed while the manager identity remains working + observable_outcomes: + - Bounded reducer stdout, durable runtime state, schedule result, and reservation outcome + determinism_controls: + - Disposable Git/control-plane fixtures and fixed protocol records + - Local SIGTERM-aware child with a readiness marker; no provider, network, retries, or sleeps + anti_cheat_rationale: + prevents: + - Solving image overflow only by shrinking a parent buffer + - Marking a missing-terminal manager wake as invoked or successful + - Accepting an earlier completed event after a later failed terminal + - Retaining private image, token, or reasoning material in durable evidence + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +## 2026-09-30 — Executor terminal-outcome integrity + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-executor-interrupted-outcomes + given: A real child that exits cleanly after SIGTERM plus deterministic signal and ENOBUFS executor results + when: Runtime startup receives a status-zero timeout, signal, or output-overflow error + then: No idle completion is recorded; captured identity remains working and failure evidence excludes private output + - id: SCN-scheduled-executor-failure + given: A scheduled command returns status zero together with ENOBUFS and screenshot-heavy output + when: The scheduler records its receipt + then: The receipt is failed, output is bounded, and credential or private-reasoning values are redacted + observable_outcomes: + - Durable runtime presence state and retained native session identifier + - Schedule result, exit status, bounded output, and redacted error evidence + determinism_controls: + - Disposable local Git fixtures and a bounded SIGTERM-aware child process + - Injected signal and ENOBUFS boundaries; no network, provider launch, retry, or sleep + anti_cheat_rationale: + prevents: + - Treating status zero as successful when Node reports timeout or output overflow + - Marking an interrupted runtime idle and permitting a duplicate launch + - Passing screenshot payloads, credentials, prompts, or private reasoning into durable receipts + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +## 2026-09-30 — Early identity and safe no-code diagnostics + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-codex-interrupted-identity + given: A real local helper whose child emits thread.started then remains alive + when: The parent observes the reduced bounded identity and sends SIGTERM to the helper + then: The helper closes by SIGTERM without a terminal success, while lifecycle retains the identity as working and uncertain + - id: SCN-codex-code-less-diagnostics + given: Code-less structured model, authentication, or quota errors containing prompt, reasoning, and image adversarial fields + when: The bounded reducer and adapter classify startup failure + then: Only the allowlisted category crosses the boundary; no free-form diagnostic or adversarial field remains + - id: SCN-cli-codex-real-executable + given: An installed disposable project and an isolated executable named codex + when: The actual runCli up command starts the selected domain + then: The reducer carries identity and terminal completion through the CLI boundary and durable identity becomes idle + observable_outcomes: + - Real helper close signal, exact reduced protocol bytes, and working retained runtime identity + - Safe diagnostic category with no prompt, reasoning, image, or free-form message + - Actual CLI JSON response and persisted identity state from a fake executable on an isolated PATH + determinism_controls: + - Local child process signal handshake driven by receipt of thread.started; a bounded watchdog only terminates and awaits the owned fixture on failed emission, never retries or asserts product timing + - Disposable Git/XDG fixtures and a hermetic executable; a tagged guard rejects an absent or mismatched env before native spawn, then delegates unchanged to native spawnSync only for the validated fake path + anti_cheat_rationale: + prevents: + - Delaying identity output until child close and losing it on interruption + - Treating code-less model/auth/quota errors as successful or retaining their raw text + - Testing only an injected launch helper instead of the CLI execution boundary + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +## 2026-09-30 — Generated exact-check artifacts + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-generated-check-artifacts + given: A clean managed worktree and an exact check that emits evidence + when: The same check runs twice through TORCH + then: Each pass retains separate external bytes and hashes bound to candidate commit, check definition, and run ID while the tested tree stays clean + - id: SCN-generated-check-mutation + given: A clean managed worktree and an exact check that emits external evidence + when: The command exits zero after mutating a tracked source file + then: The receipt remains incomplete and cannot qualify despite retained output + observable_outcomes: + - Recoverable output paths, byte counts, SHA-256 hashes, candidate/check/run provenance, and exact-pass qualification + - Git worktree status and incomplete receipt for a zero-exit source mutation + determinism_controls: + - Disposable local Git fixtures, deterministic run IDs, fixed evidence bytes, and no network or provider calls + - No retries, sleeps, baseline updates, stashes, restores, or source cleanup + anti_cheat_rationale: + prevents: + - Treating exit zero as an exact-check pass after a tracked expectation changes + - Losing or overwriting generated evidence between sequential check runs + - Claiming provenance without retained bytes and a content hash + - Hiding source mutations through automatic restore or cleanup + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + ## 2026-09-30 — Exact observed-file ownership evidence ```yaml @@ -2237,3 +2450,84 @@ test_integrity_note: did_relax_any_assertion: false if_true_explain_spec_basis: "" ``` +# Setup and canonical worktree briefs (2026-09-30) + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-guided-setup + given: "An approved installed project with uncommitted TORCH files" + when: "Owner reviews and authorizes setup" + then: "Only owned files are committed; registered worktrees exist; no providers start; staged user work blocks setup" + - id: SCN-worktree-canonical-brief + given: "A registered worktree has older instructions" + when: "Its installation root is resolved and a brief requested" + then: "Canonical instructions appear; unregistered worktrees, copied installs and stale installation IDs fail closed" + - id: SCN-install-restore + given: "A normally detached project retains configuration and worktree commits" + when: "Owner explicitly restores the install" + then: "Binding is reattached without changing profiles, worktree history or launching providers" + observable_outcomes: + - "Git commits, clean canonical checkout, worktree registrations, brief text and local detach metadata" + determinism_controls: + - "Isolated temporary Git repositories and XDG state; no provider or network calls" + anti_cheat_rationale: + prevents: + - "Hard-coded success without Git worktrees or commits" + - "Accepting copied or unregistered installation state" + - "Staging unrelated owner work" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` +# First live scheduler pilot: native systemd parser (2026-09-30) + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-systemd-native-unit + given: "A generated dispatcher with a repository path containing spaces, percent and quote characters" + when: "The real Linux systemd-analyze parser verifies the emitted service" + then: "The service parses successfully and WorkingDirectory remains an absolute scalar path" + observable_outcomes: + - "Actual native parser exit status and generated unit bytes" + determinism_controls: + - "Temporary project path alias and unit file; /usr/bin/true command; verify-only, no service activation" + anti_cheat_rationale: + prevents: + - "Mocked systemctl success masking invalid unit syntax" + - "Treating quoted ExecStart syntax as valid for scalar WorkingDirectory" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +# Codex startup runtime diagnostics (2026-09-30) + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-codex-startup-diagnostics + given: "A real Codex adapter and lifecycle plan receive deterministic nonzero child-process results" + when: "Codex stdout contains a model-rejection event, malformed JSON, or output above the diagnostic bound" + then: "Owner-visible failure details prefer the structured model rejection, redact stderr secrets and prompt fields, and label malformed or oversized output unknown with an explicit reason" + observable_outcomes: + - "FLEET_START_FAILED status, persisted offline identity state and owner-visible diagnostic details" + - "Structured model rejection over noisy MCP stderr; explicit unknown outcomes without retained oversized stdout" + - "No synthetic prompt, token or password value in serialized error details" + determinism_controls: + - "Disposable local Git/XDG fixtures and fixed injected child-process output" + - "Real createCodexAdapter and startFleet boundary; no provider calls, retries, sleeps or network" + anti_cheat_rationale: + prevents: + - "Returning a hard-coded success while losing the real nonzero execution status" + - "Displaying unrelated MCP noise instead of a structured model rejection" + - "Leaking raw prompt or credential values through diagnostics" + - "Treating malformed or oversized stdout as trusted structured evidence" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/docs/TODO.md b/docs/TODO.md index 7ec4258..c8ffd17 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,115 @@ # TORCH remaining work +## Authoritative current handoff (2026-09-30, after alpha.2 reinstall) + +TORCH is installed and healthy at `0.1.0-alpha.2`. Setup/restore and all seven +worktree briefs have been verified. The active organization is Session Manager +plus six specialists. A first bounded development pilot is now active through +Session Manager, Provider Runtime and QA; the remaining domains have not started. +See `reports/audit/first-development-pilot-2026-09-30.md` for current live state. +The default Git branch remains +legacy TORCH; the configured development/integration boundary is +`rewrite/portable-agent-fleet`. No publication or default-branch promotion is +authorized. Later sections retain historical audit snapshots, including old +"uncommitted/no launcher" statements; those are superseded by this handoff. + +The conversation's unfinished work is now owner-proposed in the **existing +authoritative backlog in the Session Manager worktree**, not a second TODO queue. +Use `torch backlog list --json` to see it from any registered checkout. Tasks use +the `TASK-conversation-` prefix, have acceptance criteria, affected domains and +dependencies, and remain unassigned for manager triage. See +`reports/audit/conversation-handoff-2026-09-30.md` for the domain review and index. + +### New owner requirements: first work and automatic operations + +- [ ] `TASK-conversation-first-work-intake`: init/install/setup offers reviewed + TODO/backlog import or asks what the owner wants to begin with; previews and + confirms proposed tasks; deduplicates resumes; never activates stale/completed + TODOs or launches agents silently. +- [ ] `TASK-conversation-persistent-operations`: reviewed init/install provisions + machine-level scheduler/services automatically, not merely schedule JSON or a + separate manual cron-install command. Paused installs remain dormant. Services + declare system/session lifetimes, budgets, timezone, restart/missed-run policy, + pause/resume and manifest-owned uninstall/restore behavior. This extends the + earlier `TASK-conversation-automatic-scheduler` requirement. +- [ ] `TASK-conversation-event-driven-wakes`: durable deduplicated significant + events wake the appropriate manager under budget; periodic checks are the quiet + safety net, not repeated usage-consuming busywork. +- [ ] `TASK-conversation-queue-watchdog`: detect stuck resource queues and + disappearing slots, audit removals and stop displaced waiters; never infer + browser ownership/termination from a process name or runner death. +- [ ] `TASK-conversation-nightly-main-tests`: project-declared full canonical + suite on an exact frozen commit, resource-aware idle window and evidence-backed + regression routing. Do not guess the offending change. +- [ ] `TASK-conversation-usage-governor`: provider-neutral usage/credit evidence, + conservative fallback budgets, graceful wind-down and reviewed reset/restart + policy that cannot override manual pause or detach. +- [ ] `TASK-conversation-idle-worktree-refresh`: scheduled merges into clean, + idle, unguarded branches; skip active/dirty/unknown work and preserve conflicts. +- [ ] `TASK-conversation-queue-planning`: measured test durations, uncertain wait + estimates, slowdown visibility, GPU-exclusive resources and approved heavy-run + windows; preserve the resource queue's authority. +- [ ] `TASK-conversation-closure-revert-review`: verified revert/follow-up policy + and evidence-backed review of unnamed finished work; never mass-close or reopen + tasks based only on untrusted commit trailers. +- [ ] `TASK-conversation-project-services`: reviewed project-specific landing + worker, local dev/Console server, local summary and optional release jobs. + No COMBATRIG schedules, Vercel deployment, credentials, LAN exposure or automatic + phone notifications are assumed. + +These are proposed development tasks, **not implemented automatic init/install**. +For the first pilot the arbiter explicitly installed an hourly machine manager +coordination timer and verified real systemd parsing/activation. Automatic AI +wakes are paused pending the timeout/outcome guard; self-claim remains disabled. +COMMON.md authorizes only the diagnostics development slice, not broad autonomous +dispatch. The original 34 proposals remain the handoff; three newly observed +pilot reliability issues have also been added to the same backlog: + +- [ ] `TASK-pilot-starting-presence`: publish guarded startup state before a + blocking provider launch and prevent duplicate manual/timer execution. +- [ ] `TASK-pilot-systemd-rendering`: native scalar-path rendering is fixed and + qualified in source; finish active/failed unit evidence and false-success + handling instead of equating installed files with an operational timer. +- [ ] `TASK-pilot-executor-outcomes`: zero exit plus timeout/error/signal cannot + produce a successful lifecycle/wake receipt; qualify before automatic wakes. +- [ ] `TASK-pilot-generated-check-artifacts`: retain exact-commit check artifacts + outside the tested tree and safely recover proven generated outputs so a gate + does not make the following required gate fail WORKTREE_DIRTY. +- [ ] `TASK-provider-handover`: after release qualification, checkpoint and safely + switch one session then the fleet to reviewed Claude Code/Pi profiles; preserve + TORCH identity/tasks/messages but never reuse another harness's native session ID. +- [ ] `TASK-owner-agent-chat`: persistent per-agent owner conversations for the + Manager and every activated domain, durable replies and clear queued/delivered/ + running/failed outcomes. Reviewed wake-on-message must respect pauses, budgets + and runtime leases; qualify executor outcomes and starting presence first. + +The owner approved continuing the controlled pilot. Independent QA now has exact +candidate `ae18a98e2911aa515d0484518013061b05ff6adf` and the Manager has a bounded +next-slice planning request. The candidate's test/check/lint receipts pass; its +dashboard receipt remains incomplete because the test writes tracked reports. +Neither review launch nor controller exit is acceptance or landing evidence. +There are now 44 owner-intake items, including agent chat, the UI audit, observed +attention/startup follow-ups and bounded routine-coordination policy. + +- [ ] `TASK-console-ux-audit`: Session Manager assigns Owner Console to audit + every live dashboard area through actual computer use and inspected screenshots, + including desktop/mobile and keyboard/refresh behavior. Owner rejects the long + single-page/anchor-navigation layout. Propose focused separate screens, concise + overview and contextual drilldowns. Audit/proposal only; no UI rewrite yet. +- [ ] `TASK-console-attention-actions`: meaningful condition/agent/branch labels, + deduplicated findings and contextual quick actions. Owner approve/reject uses + existing evidence/revision/preview confirmation; never clear peer messages, + merge branches or publish to a remote through a generic warning button. +- [ ] `TASK-pilot-brief-sandbox`: the first UI audit stopped at a read-only SQLite + error while the exact installed brief succeeds on the host. Qualify scoped + sandbox access and actionable failure; do not disable sandboxing, change DB + permissions, copy state or bypass the authoritative startup brief. +- [ ] `TASK-bounded-routine-coordination`: live COMMON/manager policy now keeps + own-inbox handling, peer questions, named approver decisions and owned ledger + maintenance inside the fleet. Project Kernel's approved implementation slice + makes this portable in generated instructions and proves authority/wait/pause + boundaries with additive scenarios. No general unattended execution is implied. + ## First real self-host run (2026-09-30) ### Portable production pipelines (new roadmap element) @@ -37,7 +147,7 @@ updates, retained previous installs, offline previews and fail-closed errors. Four deterministic scenarios added; live download qualification recorded in the first-run audit, not implied by mocked installer tests. -- [ ] Fix `torch brief` invoked from a managed worktree: both live sessions hit +- [x] Fix `torch brief` invoked from a managed worktree: both live sessions hit `LOCAL_STATE_METADATA_MISMATCH`. Resolve canonical project binding with checked worktree membership, never weaken root/installation identity checks. - [ ] Surface Codex JSON stdout failure events: first Sol launch hid the actual @@ -45,7 +155,7 @@ - [ ] Add exact executable/version/model/auth preflight to owner Console and setup flow. PATH selected CLI 0.158.0 while the current session used 0.159.2; the older child rejected Sol 6.1 and required manual investigation/retry. -- [ ] Make configuration checkpoint/worktree/manifest handoffs explicit or +- [x] Make configuration checkpoint/worktree/manifest handoffs explicit or guided: generated tracked files and provisioning manifest updates require deliberate commits; a healthy doctor does not prove startup readiness. - [ ] Isolate irrelevant inherited global MCP servers without removing user @@ -349,7 +459,7 @@ in `reports/audit/self-host-readiness-2026-09-30.md`. No agent or timer was star the collapsed panel; observation never releases a launch lock. - [ ] Qualify interrupted-wake recovery against live runtime lifecycle evidence; owner attestation is explicit but is not independent proof of termination. -- [ ] Qualify the updated source as a clean commit before self-host activation; +- [x] Qualify the updated source as a clean commit before self-host activation; older artifact receipts predate these changes. No fleet or timer has started. Review verification (2026-09-30): full regression suite through isolated @@ -961,7 +1071,7 @@ COMBATRIG's proposed hierarchy as the default. multi-domain coordination/resume scenario with a fixed provider budget. - [ ] Install and reverse the user-systemd scheduler, verifying one real dispatch and its receipts. -- [ ] Install an accepted TORCH version in user-local versioned state and use +- [x] Install an accepted TORCH version in user-local versioned state and use its stable launcher to manage this source checkout. - [ ] Resolve COMBATRIG's 56 ownership/exclusion blockers, then separately authorize an import/cutover trial. Do not mutate COMBATRIG beforehand. diff --git a/package-lock.json b/package-lock.json index b4afe31..86045d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "torch-agent-fleet", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "torch-agent-fleet", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.3", "bundleDependencies": [ "@modelcontextprotocol/server", "typebox", diff --git a/package.json b/package.json index f2b1ad6..42324e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "torch-agent-fleet", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.3", "type": "module", "description": "TORCH — a portable, provider-independent AI development fleet for Git repositories", "main": "src/cli.mjs", diff --git a/reports/audit/conversation-handoff-2026-09-30.md b/reports/audit/conversation-handoff-2026-09-30.md new file mode 100644 index 0000000..d3755ad --- /dev/null +++ b/reports/audit/conversation-handoff-2026-09-30.md @@ -0,0 +1,90 @@ +# TORCH conversation-to-fleet handoff + +## Installed domains + +The installed organization is appropriately scoped to this repository: one +Session Manager and six specialists. A current read-only ownership query over +129 tracked files under `src`, `bin`, `site`, `schemas`, `scripts` and `test` +found zero unowned files and zero duplicate primary owners. This claim does not +cover every historical report or future new directory. + +| Domain | Responsibility | Effective Codex model | +| --- | --- | --- | +| Session Manager | Routing, ownership, priority and fleet health | GPT-6.1 Sol high | +| Project Kernel | Bootstrap, identity, canonical instructions and organization | GPT-5.6 Terra high | +| Provider Runtime | Harnesses, MCP, lifecycle, schedules and telemetry | GPT-5.6 Terra high | +| Work and Integration | Backlog, checks, resources, landing and recovery | GPT-5.6 Terra high | +| Owner Console | Dashboard, artifacts, feedback and observability | GPT-6 Luna high | +| Release and Self-host | CLI, package, candidates, upgrades and removal | GPT-6 Luna high | +| Independent QA | Scenario integrity and real qualification | GPT-5.6 Terra high | + +The six-role grouping was a reviewed consolidation of repository evidence, +not a copy of COMBATRIG's specialists and not proof of a fully autonomous AI +Architect first-run. Proposed extra domain leads are inactive. Headcount alone +does not justify adding managers. Significant pipeline work may justify different +model effort or a new domain later, through the existing review path. + +## Authoritative backlog + +Owner-request intake created 34 proposed, unassigned items in the existing +Session Manager worktree backlog. There are 33 current follow-ups plus the +earlier narrower `automatic-scheduler` request, retained for manager triage +against the newer `persistent-operations` requirement rather than silently +deleted. All have acceptance criteria, affected domains and observed commit +binding; dependent slices reference existing task IDs. No assignment, provider +start, self-claim enabling or host service installation occurred. + +Run `torch backlog list --json` from any registered checkout. The canonical +backlog service resolves the manager's tracked ledger; do not create a second +copy in the source checkout. Task IDs below are prefixed `TASK-conversation-`. + +| Group | Task suffixes | +| --- | --- | +| First work / readiness | `first-work-intake`, `development-activation`, `selfhost-development-trial` | +| Persistent operations | `persistent-operations`, `automatic-scheduler`, `event-driven-wakes`, `queue-watchdog`, `nightly-main-tests`, `usage-governor`, `idle-worktree-refresh`, `queue-planning`, `project-services` | +| Provider reliability | `runtime-diagnostics`, `startup-preflight`, `mcp-isolation`, `update-startpaths`, `mixed-provider-qualification` | +| Pipelines | `pipeline-definitions`, `pipeline-provenance`, `pipeline-flow`, `pipeline-stalls`, `pipeline-console`, `pipeline-metrics`, `pipeline-pilot` | +| Live qualification | `hierarchy-pilot`, `scheduler-qualification`, `browser-portability`, `landing-closure`, `closure-revert-review`, `delivery-qualification` | +| Owner/product/release | `owner-digest`, `console-workflow`, `documentation-current`, `release-qualification` | + +## What is not yet automatic + +Setup and retained-state restore have been qualified; installed alpha.2 is +healthy. All agents remain offline. The current manager schedule declares a +15-minute interval, but no TORCH-owned systemd units are installed or active. +Read-only host timer enumeration confirmed no TORCH timer; unrelated Archive +timers were untouched. The owner's latest request is that init/install provision +these integrations automatically. That is now an explicit target in specification +section 22 and the backlog, not an already delivered feature. + +The shared common prompt still authorizes only an installation smoke test and +the project self-claim policy is disabled. Starting agents under it does not +authorize development. The manager should read and triage proposed work, recommend +a first bounded slice and budget, and obtain the development/startup approval +before delegation. New installs should offer reviewed TODO import or ask the +owner what they want first; repeated intake must deduplicate rather than queue +every historical checkbox. + +## Suggested first implementation sequence + +1. First-work intake and persistent operations planning/provisioning: make + the product's installation genuinely complete while stopped until ready. +2. Runtime diagnostics and exact launch preflight: make startup failures legible. +3. Separately approved bounded self-host development, landing/closure and + scheduler qualification, followed by persistent event/watchdog/nightly work. +4. Pipeline definitions, immutable provenance, checked transitions and Console. +5. Finish mixed-provider, organization-growth and release qualification. + +There is no authorization here to promote the rewrite to default `main`, deploy +the public website, send notifications, alter COMBATRIG, spend on asset generation +or install active recurring AI wakes. Those boundaries remain in the task criteria. + +## Validation + +Installed `torch backlog list` sees all 34 items. Acceptance criteria and domains +are populated; every dependency exists and the dependency graph is acyclic. +Installed `torch backlog health` reports healthy with no anomalous assignments. +Its bounded historical activity coverage remains incomplete, correctly labelled +unknown rather than proof of inactivity. Existing backlog/scheduling regression +suite: 23/23 PASS. All seven identities remain offline. These are backlog/spec +changes, not implementation of the newly requested onboarding or automation. diff --git a/reports/audit/first-development-pilot-2026-09-30.md b/reports/audit/first-development-pilot-2026-09-30.md new file mode 100644 index 0000000..e496594 --- /dev/null +++ b/reports/audit/first-development-pilot-2026-09-30.md @@ -0,0 +1,238 @@ +# First TORCH-building-TORCH development pilot + +## Owner authorization and live monitor + +The owner requested a staged self-development fleet, with the outer assistant +as arbiter through Session Manager, then a quality release/update and a later +Claude Code/Pi substitution test. COMMON.md records the bounded first slice: +`TASK-conversation-runtime-diagnostics`. Other development work, remote pushes, +landing, public release, deployment, default-main promotion and provider migration +are not authorized by this pilot. + +Monitor: **http://127.0.0.1:4174/console**. The snapshot is installed mode for +project `3dc11932-6e3c-42aa-9a8a-ca7b8f818b54`, not demo state. The original +Console process died during an outer-daemon restart. It was restored as the +loopback-only transient user service +`torch-3dc11932-6e3c-42aa-9a8a-ca7b8f818b54-console-pilot.service`, outside the +assistant process lifetime. This is not yet a reboot-persistent installed +Console integration; automatic service provisioning remains backlog work. + +## Real agent evidence + +- Session Manager resumed the same Sol runtime and assigned the real task to + Provider Runtime with revision-checked proposed → ready → assigned transitions. +- Provider Runtime started as the configured Terra model, retained runtime + session `01a0f3e1-9106-72c3-b8b2-2f1cc5322ccc`, queried ownership and requested + QA consent before editing tests. It committed production changes at + `c362ccd5cc8fff94c59d4a0c8f178628dc5e7596` on its own branch. That is not landed + or accepted release evidence. +- QA ran real Terra coordination turns and gave exact additive-test consent for + `test/fleet/lifecycle.test.mjs` and `docs/TEST_INTEGRITY.md`, retaining existing + assertions and requiring independent exact-candidate review. +- A daemon restart interrupted implementation. The arbiter checked owned live + Codex process absence, preserved dirty code/tests/generated screenshots and + resumed the same assignment via a transient `runtime-pilot-turn` user service. + No unrelated process was killed or user work reverted. + +Provider-reported focused checks passed, but its generic full-suite failures and +final regression evidence still require exact-source qualification. Do not infer +completion from a commit, active process or green controller receipt. Generated +test screenshots remain agent work and must not be blindly committed as changed +expectations. First launch presence lag and assigned-versus-working task state +need review; startup badges are not independent process-liveness proof. + +## Machine scheduler: qualified setup, guarded AI execution + +The owner-approved hourly check-in policy retains a maximum of 12 automatic +invocations per UTC day in subscription invocation-count mode, not a USD cap. +The project-owned persistent systemd timer was explicitly installed for the +pilot. This manual qualification does not implement automatic init/install yet. + +Initial live install exposed invalid `WorkingDirectory="/absolute/path"` output. +The actual parser rejected it, while file-based status said installed and the +timer was enabled but inactive. Minimal source repair `d636cae` emits an absolute +scalar path with C escaping and literal specifier handling. A new native-parser +scenario includes spaces, percent and quote characters. Fourteen scheduling tests +and all 39 source acceptance groups PASS; no existing test was weakened. + +Reconciliation used the tested source CLI and preserved owned-file hashes. The +timer is active and its first dispatch produced a durable check-in and wake +receipt. Installed stable tool remains alpha.2; the repaired scheduler currently +executes the source CLI, not a newly packaged stable release. A later qualified +tool update must incorporate the repair and reconcile the launcher. + +The first automatic manager invocation lasted exactly the 300-second executor +limit and was recorded succeeded/invoked. Independent model-turn completion was +not established. A native local child trapping SIGTERM reproduced +`status: 0`, `signal: null`, `error: ETIMEDOUT`; current startFleet ignores the +error when exit status is zero. This confirms a false-success boundary, not +independent proof of how that particular model turn ended. Historical receipts +were preserved, not rewritten. + +Automatic AI wakes are therefore **disabled** until `TASK-pilot-executor-outcomes` +is fixed and qualified. The timer remains active for durable machine coordination; +the arbiter explicitly drives the bounded specialist turns. Do not claim the +fleet is ready for unattended autonomy. + +## Backlog and next gates + +The original 34 conversation follow-ups are preserved. Three observed pilot +issues were added through audited owner intake: starting presence, native unit +rendering/operational status and executor timeout/outcome semantics. The manager +backlog remains the authoritative queue, separate from this narrative report. + +Recovery follow-up: the specialist produced regression commit `2f1ca15` but exact +TORCH check planning was blocked by five tracked report images generated by the +dashboard gate. The owner/arbiter gave narrow reversible archive-and-restore +directions for proven check-owned outputs, not permission to rewrite expectations. +`TASK-pilot-generated-check-artifacts` records the durable fix. The explicitly +requested post-quality-release whole-fleet provider migration now has its own +`TASK-provider-handover`, dependent on release qualification. Total intake is +39 items (34 original follow-ups plus five later pilot/handover items); one real +implementation assignment does not imply the rest are dispatched. + +1. Finish diagnostics regression evidence and independent QA review. +2. Fix executor outcome/launch-presence guards and qualify one real bounded wake. +3. Complete reviewed automatic init/install onboarding and persistent operations. +4. Expand the fleet's scope only after a successful bounded work/verification/ + landing/resume cycle; never directly push competing specialist branches. +5. Build/accept/update a quality artifact with current receipts, then separately + test Claude/Pi handover of the same durable identities, tasks, messages and + checkpoints. No model/provider swap occurred in this pilot. + +Receipts are under `/tmp/torch-pilot-*`, including repaired full-source acceptance, +native parser tests, scheduler installation/reconciliation, durable wake/run +state and owner-request messages. The live dashboard and actual branch state +take precedence over historical progress descriptions in this report. + +## Continued pilot and owner UX feedback + +The owner approved continuing the controlled pilot and is watching the Console. +Independent QA reviewed candidate `ae18a98e2911aa515d0484518013061b05ff6adf`: +the lifecycle scenario independently PASSed 7/7, with no assertion relaxation. +QA accepted code/scenario content, **not integration qualification**. Required +dashboard receipt remains incomplete and canonical convergence remains required. +QA's durable report is message `8275d64a-7b28-49ee-a379-2c6d0bdcacb3`. + +Two additional owner-intake proposals bring the backlog to 41: + +- `TASK-owner-agent-chat`: threaded durable owner conversations and reviewed + wake-on-message after executor/presence qualification. Current agent message + recipient validation does not permit the owner as a recipient; owner replies + need a distinct authorized API, not an invented specialist identity. +- `TASK-console-ux-audit`: the owner rejects excessive scrolling and a generic + all-in-one-page dashboard. Session Manager has an explicit bounded assignment + request for existing Owner Console. Require actual computer-use navigation, + inspected screenshots of every current area, desktop/mobile and interaction + coverage, and a focused multi-screen proposal. Live state is read-only; demo + may exercise mutations. Missing browser tools are a blocker, not permission + to claim a source-only audit completed the request. + +Canonical COMMON now permits this audit in parallel with diagnostics QA, without +authorizing UI implementation, unrelated dispatch, automatic wakes or release. +The arbiter launches Owner Console only after actual manager assignment; a +request in an inbox is not itself assignment evidence. + +## Attention-panel triage and audit recovery + +Session Manager assigned `TASK-console-ux-audit` to Owner Console at revision 3. +The first audit turn **did not perform browser coverage**: installed `torch brief` +failed with `ERR_SQLITE_ERROR` / read-only database. The identical installed brief +passes from the host. The arbiter resumed the same session with directions to +request narrowly scoped escalation for that exact command, not change permissions +or skip the guard. `TASK-pilot-brief-sandbox` captures durable qualification. + +Owner additionally requests contextual quick actions in Needs your attention. +`TASK-console-attention-actions` requires meaningful per-agent/branch findings, +deduplicated messages and owner-only reviewed approve/reject actions. Total +owner intake is now 43. It does not authorize warning dismissal, blanket message +acknowledgement, automatic merging or remote publication. + +Observed worktree warnings: manager backlog updates are dirty, and manager and +Provider Runtime retain unique commits. These are preserved task state and +unlanded work, not permission to remove worktrees or discard commits. The manager +has a narrowly authorized local backlog checkpoint/convergence request and must +review its own inbox. Provider code still requires integration gates. + +Recovery is `ONE-DISK` for current rewrite HEAD: origin exists, but local +remote-tracking references do not contain that commit. This is not proof of a +live remote query. Off-machine synchronization requires an explicit owner +publication/backup decision; no remote push or default-main promotion occurred. + +## Bounded coordination and reliability follow-through + +The owner clarified that ordinary peer coordination should not require human +relay. Live COMMON and Session Manager instructions now require own-inbox review, +named-approver decisions, durable unresolved waits and direct responsible-peer +follow-up. Spending, publication, releases, scope/ownership changes, destructive +recovery and owner-addressed approvals remain reserved. There are 44 backlog +items; `TASK-bounded-routine-coordination` carries this policy into fresh installs. + +Project Kernel committed candidate `d3a679849b0f5e55c2114223a2e56977c318391e`. +The arbiter independently ran its new routine-coordination test: **2/2 PASS**. +This covers generated instruction surfaces, acknowledgement not completing work, +and named-approver enforcement. It does not independently prove paused runtime +execution or full integration qualification. QA has a review request for that +coverage gap. Kernel reports test/check/lint evidence, but its dashboard gate is +blocked: root Playwright is 1.58.2, Kernel's installed dependency is 1.63.0, and +the latter expects absent `chromium_headless_shell-1243`. QA and manager were +asked to resolve reproducible setup, not waive assertions or use an unverified +browser substitution. + +Session Manager explicitly blocked the earlier runtime diagnostics task while +preserving its accepted candidate and incomplete dashboard receipt. It then +assigned `TASK-pilot-executor-outcomes` to Provider Runtime and +`TASK-pilot-generated-check-artifacts` to Work and Integration, one active item +per specialist. Named QA additive-test/output-plumbing consent and Release CLI +consent were requested through the control plane. The arbiter started their +review turns and both assigned implementation turns using separate owned +transient user services. A running service proves an active launch handle, not +task completion or successful native terminal output. + +All three named peer consents subsequently reached **approved revision 2**: +QA `e198e23b-772f-42d5-89df-5c4891df904f` and +`649f7e47-8b94-4b02-a1e6-962fde3e24a7`, and Release +`200dcdba-9337-4e6f-a218-d02c7bda458a`. The responsible peers sent their bounded +decisions directly to specialists and manager. Actual native Codex processes +were verified in the Provider Runtime and Work and Integration worktrees; +the Console subsequently showed both as working. These are successful peer +coordination outcomes, not completed implementation or independent acceptance. + +Owner Console is continuing its actual browser audit through a bounded temporary +diagnostic launcher. The launcher captures outcome metadata only, checks terminal +turn evidence and refuses false success. Its larger output buffer is diagnostic +recovery, **not an installed engine fix**. The permanent executor task must handle +timeouts, signals, errors, output overflow and uncertain captured identities. +Automatic wakes remain paused; no remote push, release, installation update or +default-main promotion has occurred. + +## Independent follow-up: completion evidence and review gaps + +The first temporary Console probe completed with status 0, no executor error or +signal, 64 valid protocol records and a final `turn.completed` event. It captured +141,317 stdout bytes. This proves that diagnostic turn completed, **not that its +audit task completed**; the audit addendum still records missing computer-use +coverage. A fresh arbiter browser connection and fresh tab subsequently produced +an actually viewed single-viewport Overview capture in about four seconds. The +same Console identity was resumed with that recovery evidence, without duplicate +launch or a claim that the earlier interruption was caused by output overflow. + +QA independently confirmed Kernel's focused 2/2 pass, but rejected full policy +acceptance because paused execution was only text-matched and manual test reports +were not durable TORCH check receipts. Kernel was resumed for an additive real +paused-dispatch boundary scenario. Both tracked lockfiles pin Playwright 1.58.2; +Kernel's 1.63.0 installation is an environment mismatch, not an intended upgrade. + +Work and Integration created candidate +`f46c713cc9e90697d02e3e29222b3eeb7cd71d99`. Arbiter independently ran its two new +artifact scenarios: **2/2 PASS**, covering sequential external hashed outputs +and an exit-zero source mutation remaining incomplete. Full dashboard, +convergence and independent acceptance remain outstanding. + +An early review of Provider Runtime's uncommitted executor draft found remaining +workflow gaps: status-zero success without terminal evidence, reducing scheduled +output capacity rather than safely handling screenshot-rich turns, discarding +initial runtime identity on large output, and insufficient public-boundary tests. +Specific evidence was routed to Provider Runtime, QA and Session Manager. This +is in-progress review feedback, not an accepted fix. The manager was resumed to +follow direct-report waits and task/evidence state; automatic wakes remain paused. diff --git a/reports/audit/setup-reinstall-2026-09-30.md b/reports/audit/setup-reinstall-2026-09-30.md new file mode 100644 index 0000000..b96e5f8 --- /dev/null +++ b/reports/audit/setup-reinstall-2026-09-30.md @@ -0,0 +1,59 @@ +# Guided setup and reinstall verification + +## Changes + +- `torch brief` and normal installed-project CLI commands resolve registered + worktrees to the canonical project. The resolver checks project and installation + IDs, XDG state binding, Git common directory, worktree membership and branch. + Current canonical instructions win over an old worktree copy. +- `torch setup --dry-run`, then `--yes`, checkpoints owned configuration, + provisions worktrees and checkpoints registration. Existing staged work is + rejected; unrelated files are not staged. Install can opt in with `--setup`. +- `torch install --restore` reattaches the retained state from normal uninstall. + It does not overwrite profiles, erase history, recreate timers or start agents. +- The self-host smoke brief distinguishes manager backlog inspection from + specialist next-task lookup and explicitly requires current briefs. + +## Evidence before installation + +All 39 source acceptance groups, lint and syntax: PASS. +Receipt: `/tmp/torch-alpha2-source-acceptance.json`. +New scenarios: guided setup, canonical worktree briefs, safe detach/restore. +The actual Owner Console worktree returned its current brief successfully +through the patched source CLI, including its preserved offline runtime ID. + +## Installation trial + +Accepted alpha.2 source: `c11754ab18fa690355ead3f6f87b3accb2975153`. +Candidate digest: `d4b6ba38a51cc1804047b175c963806850f017a92d88863d187043314414dd90`. +All 39 acceptance groups passed again from the copied candidate. Active version +is `0.1.0-alpha.2`; rollback retains alpha.1. + +Normal uninstall: PASS. Retained-state restore: PASS. All seven actual agent +worktrees returned briefs through `/home/user/.local/bin/torch`: PASS. Their +briefs include the updated canonical smoke instructions despite older worktree +copies. Model configuration hash, project/installation IDs, offline states, +saved Codex runtime IDs and all seven branch tips were unchanged across +uninstall/restore. No provider was launched. + +The restored project's `torch setup --yes --json` was a no-op: no commits, +no mutations, no sessions started. Final doctor reports healthy. Modified owned +configuration and unique manager history remain legitimate safety warnings, +not authorization to purge. Uninstall refreshed the manager resume brief; +that generated file was separately checkpointed on its existing branch, without +rewriting prior history or landing anything. + +Receipts: + +- `/tmp/torch-alpha2-candidate-build.json` +- `/tmp/torch-alpha2-uninstall-plan.json` +- `/tmp/torch-alpha2-uninstall-result.json` +- `/tmp/torch-alpha2-restore-result.json` +- `/tmp/torch-alpha2-reinstall-verification.json` +- `/tmp/torch-alpha2-real-setup-result.json` +- `/tmp/torch-alpha2-real-doctor.json` + +This tests retained-state reinstall, not destructive purge or a fresh installation +with discarded configuration. New-install setup is covered by isolated real-Git +regression tests. Provider-authentication and new agent launches were deliberately +not repeated. Host timers remain uninstalled. diff --git a/reports/design-system/live-console-arbiter-audit-2026-09-30.md b/reports/design-system/live-console-arbiter-audit-2026-09-30.md new file mode 100644 index 0000000..417a239 --- /dev/null +++ b/reports/design-system/live-console-arbiter-audit-2026-09-30.md @@ -0,0 +1,145 @@ +# Live Console arbiter usability audit + +## Evidence and scope + +Owner requested direct inspection of `http://127.0.0.1:4174/console` and a report +to Session Manager. Inspected the real installed workspace in the connected Brave +browser using interactive navigation, DOM observations and actually viewed +viewport screenshots. No live task, approval, profile, release or message was +mutated through the browser. Expanded the request composer and one incomplete +check receipt only. Screenshots were viewed in this audit conversation; no new +artifact catalog or saved screenshot files are claimed. + +Visited all ten navigation entries: Overview, Briefing, Flow watch, Work, +Progress, Fleet, Conversations, Organization, Evidence and Release gates. +Temporary narrow viewport was reset afterward. Desktop DOM viewport was +1253×685. Requested mobile override 390×844 produced reported CSS viewport +433×938 in this browser; responsive measurements below use the reported values. +No full keyboard audit, draft/refresh experiment, approval submission, empty/error +simulation or hidden Operations panel coverage is claimed. Those remain the +assigned UI agent's coverage requirements. + +## Prioritized findings + +### P1: one document makes monitoring impractical + +Desktop document height measured 50,288 pixels. Work occupied 36,662 pixels, +Conversations 5,723 and Organization 2,931 at the observation point. Navigation +changes URL fragments and scroll position rather than opening focused views. +The masthead/project/freshness controls scroll away on lower sections. Current +nav styling exists, but every navigation link lacked `aria-current`. + +Use addressable separate screens with persistent project context. Keep Overview +to a concise pulse, real decisions, blockers and recent outcomes. Route detail +links to exact task, agent, check or decision rather than a broad section. + +### P1: work-card density dominates the page + +There were 43 active tasks, including 41 Queue cards. Narrow desktop kanban +columns wrap IDs and titles heavily and show full long descriptions. Filtering, +saved-view editing and task creation sit above the actual work. At narrow size, +the filter block alone measured approximately 572 CSS pixels, followed by a +horizontally navigated kanban. + +Use compact task summaries with a detail drawer/page. Separate proposed backlog +from active work by default; retain one authoritative task system. Put secondary +filters/saved-view editing behind an intentional control. Mobile should offer a +usable list or selected-column view, not require navigating every column. + +### P1: attention is opaque and duplicates the same condition + +Actual Overview repeated MESSAGE_BACKLOG plus the unacknowledged message count, +and three WORKTREE_PROBLEM entries all said only “Doctor reported a project +condition to review.” Unique commits and manager backlog edits are not equivalent +to a failed merge or missing tree. The Healthy badge can imply more operational +readiness than these conditions justify; the existing doctor threshold may be +valid, but the UI label should describe its narrower meaning. + +Implement `TASK-console-attention-actions`: group and deduplicate actual entities, +name identity/branch/reason, distinguish safety gates from routine agent progress. +Offer owner Approve/Reject only for an actual pending owner-addressed approval, +using current revision/evidence preview-confirm checks. Offer exact inspect, +message/review or supported recovery actions otherwise. No blanket dismiss, +peer-message acknowledgement, merge, deletion or publication shortcut. + +### P1: conversations is an internal transcript, not owner chat + +Two-column entries expose entire long coordination instructions. There is no +agent-thread picker, compact subject/task grouping or focused reply experience. +The expanded installed request composer displayed “Install a reviewed Fleet +before sending owner requests” while identity options were populated and Preview +request was present. This contradicts the installed workspace state. Existing +request composition is not a native owner reply channel or automatic wake. + +Implement `TASK-owner-agent-chat` with truthful per-agent threading, delivery and +reply states. Keep engineering message detail expandable; show concise relevant +summaries first. Fix stale placeholder status from actual capability state. + +### P2: fleet, ownership and organization repeat technical metadata + +Fleet repeats runtime/model/profile text and mixes implementation paths with +session monitoring. Organization cards repeat peer links and large owned-path +lists; the QA role shows an extensive historical report-file inventory. These +are useful audit details but poor default monitoring content. + +Use a compact agent table/list showing work, state, last evidence, blocker and +chat/open actions. Put ownership paths and authority into agent detail. Use an +actual hierarchy/relationships view for Organization with a details inspector. +Do not replace observed presence with inferred process liveness. + +### P2: briefing and progress do not provide a quick owner outcome + +Briefing was unpublished and instructed the owner to run digest build/publish CLI +commands. Progress showed feature/milestone group totals but did not provide an +obvious exact-task drilldown. Preserve its good distinction between task progress +and verification/release readiness. + +Provide permission-aware local generate/review/publish flow when supported and +link progress groups to filtered work. Do not send notifications or publish a +release as a side effect of preparing a local briefing. + +### P2: evidence is honest but recovery navigation is weak + +Expanded test-dashboard receipt correctly showed incomplete, +worktree-changed-during-check, exact SHA and missing frozen/condition evidence. +This is valuable and must remain strict. Artifacts are not connected yet; +integration/delivery records are empty and correctly avoid implying deployment. +Flow watch still said timer installation not verified despite the earlier +host-qualified timer; review how operational evidence reaches this snapshot, +rather than merely removing the warning. + +Add links from an incomplete check to its task, author, candidate and blocking +condition. Show the reviewed next step without turning “retry” into an automatic +repeat of uncertain effects. Place receipts and advanced operational detail on +the relevant screen, not below every unrelated workflow. + +## Proposed information architecture + +- Overview: concise project outcome, decisions, blockers, recent events. +- Work: active work; compact proposed backlog; filters and exact task detail. +- Agents: state/work/waits, per-agent chat and profile/ownership detail. +- Pipelines: conditional project-defined flows, deliverables and stage history. +- Evidence: checks/artifacts with exact task/commit links and review actions. +- Releases: integration, delivery and separately authorized recovery. +- Organization / Settings: hierarchy, ownership, operations and configuration. + +Briefing may be accessible from Overview; advanced operational panels should have +their own destination. Retain dark mode and original TORCH mark. Avoid filling +every view with duplicate cards or decorative labels. + +## Implementation acceptance recommendations + +1. A navigation action displays only its focused workspace, preserves project + identity/freshness, and supports addressable reload/back/forward behavior. +2. Forty-plus proposed tasks do not make Overview or agent conversations taller; + compact cards and bounded/paginated detail preserve all underlying records. +3. Attention is entity-specific and deduplicated, with authority-aware actions + and unchanged stale-preview/revision/evidence safety checks. +4. Installed request status derives from current capability, not leftover loading + copy. Per-agent threads remain readable after reload and provider changes. +5. Narrow/mobile monitoring reaches work and actions without a full-screen wall + of filters; current navigation is accessible via focus and `aria-current`. +6. Independent browser scenarios verify navigation, refresh/drafts, mobile, + pending approvals, wrong approver, incomplete checks and failed launches. + +This report is audit evidence and a proposal, not implementation qualification. diff --git a/scripts/test-dashboard-demo.mjs b/scripts/test-dashboard-demo.mjs index 770593a..a36f3d8 100644 --- a/scripts/test-dashboard-demo.mjs +++ b/scripts/test-dashboard-demo.mjs @@ -2,8 +2,14 @@ import assert from 'node:assert/strict'; import { mkdirSync } from 'node:fs'; import { once } from 'node:events'; import { chromium } from '@playwright/test'; +import { join } from 'node:path'; import { createConsoleServer } from '../src/console/server.mjs'; +const screenshotDirectory = process.env.TORCH_CHECK_ARTIFACT_DIR || 'reports/design-system'; +function screenshotPath(name) { + return join(screenshotDirectory, name); +} + const server = createConsoleServer(); server.listen(0, '127.0.0.1'); await once(server, 'listening'); @@ -35,13 +41,13 @@ try { assert.equal(await page.locator('#operation-outcomes button').count(), 0); await page.locator('#operation-outcomes .check-evidence').last().locator('summary').click(); assert.match(await page.locator('#operation-outcomes').innerText(), /transient failure.*effects unknown/s); - mkdirSync('reports/design-system', { recursive: true }); - await page.locator('#operation-outcomes').screenshot({ path: 'reports/design-system/torch-dashboard-operation-outcomes.png' }); + mkdirSync(screenshotDirectory, { recursive: true }); + await page.locator('#operation-outcomes').screenshot({ path: screenshotPath('torch-dashboard-operation-outcomes.png') }); await page.locator('#activity-review > summary').click(); assert.match(await page.locator('#activity-review').innerText(), /4 days without a named commit/); assert.match(await page.locator('#activity-review').innerText(), /waiting may be expected/); - mkdirSync('reports/design-system', { recursive: true }); - await page.locator('#activity-review').screenshot({ path: 'reports/design-system/torch-dashboard-activity-review.png' }); + mkdirSync(screenshotDirectory, { recursive: true }); + await page.locator('#activity-review').screenshot({ path: screenshotPath('torch-dashboard-activity-review.png') }); assert.match(await page.locator('#check-list').innerText(), /Running — completion unconfirmed/); await page.locator('#check-list .check-evidence').filter({ hasText: 'interrupted-browser' }).locator('summary').click(); assert.match(await page.locator('#check-list').innerText(), /Abandoned — not qualified/); @@ -51,14 +57,14 @@ try { await page.locator('#check-list .check-evidence').filter({ hasText: 'recovery-scenario' }).locator('summary').click(); assert.match(await page.locator('#check-list').innerText(), /clockMoving.*true/s); assert.match(await page.locator('#check-list').innerText(), /not independent sensor truth/); - mkdirSync('reports/design-system', { recursive: true }); - await page.locator('#check-list').screenshot({ path: 'reports/design-system/torch-dashboard-check-evidence.png' }); + mkdirSync(screenshotDirectory, { recursive: true }); + await page.locator('#check-list').screenshot({ path: screenshotPath('torch-dashboard-check-evidence.png') }); assert.match(await page.locator('#owner-briefing').innerText(), /Choose the project home layout/); assert.match(await page.locator('#owner-briefing').innerText(), /not independent live verification/); await page.locator('#owner-briefing .briefing-detail').first().locator('summary').click(); assert.match(await page.locator('#owner-briefing').innerText(), /Landed — not necessarily deployed/); - mkdirSync('reports/design-system', { recursive: true }); - await page.locator('#owner-briefing').screenshot({ path: 'reports/design-system/torch-dashboard-owner-briefing-desktop.png' }); + mkdirSync(screenshotDirectory, { recursive: true }); + await page.locator('#owner-briefing').screenshot({ path: screenshotPath('torch-dashboard-owner-briefing-desktop.png') }); assert.match(await page.locator('#attention-list').innerText(), /manager launch needs inspection/); await page.locator('#attention-list a[href="#manager-wakes"]').click(); assert.equal(await page.locator('#manager-wakes').isVisible(), true); @@ -93,8 +99,8 @@ try { await conclusion.locator('[data-review-confirm]').waitFor(); await conclusion.getByText('Exact proposal and evidence in this decision', { exact: true }).click(); assert.match(await conclusion.locator('[data-review-scope]').innerText(), /schedulesRetained/); - mkdirSync('reports/design-system', { recursive: true }); - await conclusion.screenshot({ path: 'reports/design-system/torch-dashboard-pilot-conclusion.png' }); + mkdirSync(screenshotDirectory, { recursive: true }); + await conclusion.screenshot({ path: screenshotPath('torch-dashboard-pilot-conclusion.png') }); await conclusion.locator('[data-review-confirm]').click(); await page.locator('#organization-action-status').filter({ hasText: 'recorded as reversed' }).waitFor(); assert.equal(await page.locator('.agent-row').count(), 6); @@ -149,12 +155,12 @@ try { await page.locator('[data-review-confirm]').click(); await page.locator('#organization-action-status').filter({ hasText: 'recorded as approved' }).waitFor(); - mkdirSync('reports/design-system', { recursive: true }); + mkdirSync(screenshotDirectory, { recursive: true }); await pilotPanel.locator(':scope > summary').click(); - await pilotPanel.screenshot({ path: 'reports/design-system/torch-dashboard-pilot-review.png' }); - await page.locator('#manager-wakes').screenshot({ path: 'reports/design-system/torch-dashboard-manager-wakes.png' }); + await pilotPanel.screenshot({ path: screenshotPath('torch-dashboard-pilot-review.png') }); + await page.locator('#manager-wakes').screenshot({ path: screenshotPath('torch-dashboard-manager-wakes.png') }); await page.evaluate(() => scrollTo(0, 0)); - await page.screenshot({ path: 'reports/design-system/torch-dashboard-demo-desktop.png' }); + await page.screenshot({ path: screenshotPath('torch-dashboard-demo-desktop.png') }); await page.setViewportSize({ width: 390, height: 844 }); // Let responsive styles and layout finish before measuring the new viewport. await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); @@ -165,13 +171,13 @@ try { const bounds = element.getBoundingClientRect(); return { tag: element.tagName, id: element.id, className: element.className, right: bounds.right, width: bounds.width }; }).filter((element) => element.right > innerWidth + 1).slice(-30) })))); - await page.screenshot({ path: 'reports/design-system/torch-dashboard-demo-mobile.png' }); + await page.screenshot({ path: screenshotPath('torch-dashboard-demo-mobile.png') }); await page.locator('.console-rail a[href="#owner-briefing"]').click(); const briefingHeading = await page.locator('#owner-briefing-title').boundingBox(); const stickyRail = await page.locator('.console-rail').boundingBox(); assert.ok(briefingHeading.y >= stickyRail.y + stickyRail.height, 'briefing anchor must clear sticky mobile navigation'); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); - await page.locator('#owner-briefing').screenshot({ path: 'reports/design-system/torch-dashboard-owner-briefing-mobile.png' }); + await page.locator('#owner-briefing').screenshot({ path: screenshotPath('torch-dashboard-owner-briefing-mobile.png') }); await page.locator('#owner-briefing .briefing-detail').first().locator('summary').click(); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, 'expanded briefing evidence must fit mobile width'); await page.locator('#reset-demo').click(); @@ -236,7 +242,7 @@ try { assert.equal(await livePage.locator('#task-create-form [name="title"]').inputValue(), 'My unfinished task'); await livePage.locator('#refresh-console').click(); assert.equal(await livePage.locator('#runtime-profile-model').inputValue(), 'owner-draft-model'); - await livePage.screenshot({ path: 'reports/design-system/torch-dashboard-live-refresh.png' }); + await livePage.screenshot({ path: screenshotPath('torch-dashboard-live-refresh.png') }); await livePage.close(); assert.deepEqual(errors, []); assert.deepEqual(apiRequests, [], 'demo must never contact live project APIs'); diff --git a/src/adapters/codex.mjs b/src/adapters/codex.mjs index 2652312..9ec7447 100644 --- a/src/adapters/codex.mjs +++ b/src/adapters/codex.mjs @@ -69,6 +69,10 @@ function launchPolicyArgs(policy = {}) { return args; } +const MAX_STARTUP_OUTPUT_BYTES = 16 * 1024; +const MAX_STARTUP_EVENT_COUNT = 64; +const MAX_DIAGNOSTIC_TEXT_LENGTH = 512; + function parseJsonLines(output) { const records = []; for (const line of String(output ?? '').split('\n').map((value) => value.trim()).filter(Boolean)) { @@ -77,6 +81,91 @@ function parseJsonLines(output) { return records; } +function redactDiagnosticText(value, limit = MAX_DIAGNOSTIC_TEXT_LENGTH) { + if (typeof value !== 'string') return null; + const compact = value.replace(/\s+/g, ' ').trim(); + if (!compact) return null; + return compact + .replace(/\bBearer\s+[-A-Za-z0-9._~+/]+=*/gi, 'Bearer [REDACTED]') + .replace(/((?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|token|password|secret|credential|authorization|cookie|prompt|private reasoning)\s*(?:=|:|\s)\s*)([^\s,;]+)/gi, '$1[REDACTED]') + .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[REDACTED]@') + .slice(0, limit); +} + +function boundedStartupOutput(output) { + const text = typeof output === 'string' ? output : String(output ?? ''); + if (Buffer.byteLength(text, 'utf8') > MAX_STARTUP_OUTPUT_BYTES) { + return { records: null, reason: 'stdout-exceeds-bound' }; + } + const lines = text.split('\n').map((value) => value.trim()).filter(Boolean); + if (lines.length > MAX_STARTUP_EVENT_COUNT) return { records: null, reason: 'stdout-too-many-events' }; + const records = []; + for (const line of lines) { + try { records.push(JSON.parse(line)); } catch { return { records: null, reason: 'stdout-malformed-json' }; } + } + return { records, reason: null }; +} + +function startupFailureEvent(record) { + if (!record || typeof record !== 'object' || Array.isArray(record)) return null; + if (record.type === 'error' || record.type === 'turn.failed' || record.type === 'turn.error') { + const error = record.error && typeof record.error === 'object' && !Array.isArray(record.error) + ? record.error : record; + return { + code: error.code ?? record.code, + category: error.category ?? record.category, + message: error.message ?? record.message, + }; + } + return null; +} + +function safeDiagnosticCode(value) { + return typeof value === 'string' && /^[A-Z][A-Z0-9_.-]{0,79}$/.test(value) ? value : null; +} + +function safeDiagnosticCategory(value) { + return ['model-rejection', 'authentication', 'quota'].includes(value) ? value : null; +} + +function categoryFromDiagnosticCode(code) { + if (!code) return null; + if (/(?:MODEL|UNSUPPORTED|UNAVAILABLE|NOT_FOUND)/.test(code)) return 'model-rejection'; + if (/(?:AUTH|UNAUTHORIZED|FORBIDDEN|CREDENTIAL)/.test(code)) return 'authentication'; + if (/(?:QUOTA|RATE_LIMIT|RATE-LIMIT|LIMIT_EXCEEDED)/.test(code)) return 'quota'; + return null; +} + +export function diagnoseCodexStartupFailure({ stdout, stderr } = {}) { + const parsed = boundedStartupOutput(stdout); + const safeStderr = redactDiagnosticText(stderr); + if (!parsed.records) { + return { + schema: 'torch.dev/runtime-startup-diagnostic/v1alpha1', outcome: 'unknown', reason: parsed.reason, + stderr: safeStderr, + }; + } + const failure = parsed.records.map(startupFailureEvent).find(Boolean); + if (!failure) { + return { + schema: 'torch.dev/runtime-startup-diagnostic/v1alpha1', outcome: 'unknown', reason: 'stdout-no-failure-event', + stderr: safeStderr, + }; + } + const message = redactDiagnosticText(failure.message); + const code = safeDiagnosticCode(failure.code); + const category = safeDiagnosticCategory(failure.category); + const modelRejection = /\b(model|unsupported|not supported|unavailable|not available|does not exist)\b/i.test(message ?? '') + || /MODEL|UNSUPPORTED/i.test(code ?? ''); + return { + schema: 'torch.dev/runtime-startup-diagnostic/v1alpha1', outcome: 'known', + category: category ?? categoryFromDiagnosticCode(code) ?? (modelRejection ? 'model-rejection' : 'runtime-error'), + ...(code ? { code } : {}), + ...(message ? { message } : {}), + stderr: safeStderr, + }; +} + export class CodexRuntimeAdapter { constructor({ env = process.env, @@ -227,6 +316,10 @@ export class CodexRuntimeAdapter { return { areaId: text(areaId, 'areaId'), runtimeSessionId: text(captured, 'runtimeSessionId') }; } + diagnoseStartupFailure({ stdout, stderr } = {}) { + return diagnoseCodexStartupFailure({ stdout, stderr }); + } + getStatus() { return unsupportedCapability(this.name, 'getStatus'); } listSessions() { return unsupportedCapability(this.name, 'listSessions'); } diff --git a/src/checks/conditions.mjs b/src/checks/conditions.mjs index 02e95e8..36f40e3 100644 --- a/src/checks/conditions.mjs +++ b/src/checks/conditions.mjs @@ -18,7 +18,7 @@ function inspectReport(policy, report, subject) { return null; } -function probe({ definition, subject, cwd, executor, clock }) { +function probe({ definition, subject, cwd, executor, clock, environment = {} }) { const policy = definition.conditions; const startedAt = clock().toISOString(); let result; @@ -26,7 +26,8 @@ function probe({ definition, subject, cwd, executor, clock }) { result = executor(policy.command, policy.args ?? [], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: (policy.timeout_seconds ?? 30) * 1000, maxBuffer: MAX_REPORT_BYTES, - env: { ...process.env, TORCH_CHECK_COMMIT: subject.commit, TORCH_CHECK_INPUT_DIGEST: subject.inputDigest ?? '' }, + env: { ...process.env, ...environment, + TORCH_CHECK_COMMIT: subject.commit, TORCH_CHECK_INPUT_DIGEST: subject.inputDigest ?? '' }, }); } catch (error) { return { valid: false, reason: 'condition-probe-executor-failed', startedAt, @@ -60,7 +61,7 @@ export function validConditionEvidence({ definition, conditions, commit }) { && (before.runtimeId === undefined || after.runtimeId === before.runtimeId); } -export function executeConditionedCheck({ definition, subject, cwd, executor, clock }) { +export function executeConditionedCheck({ definition, subject, cwd, executor, clock, environment = {} }) { const conditions = definition.conditions ? { provenance: 'project-reported', subject, required: definition.conditions.required, before: null, after: null, @@ -68,7 +69,7 @@ export function executeConditionedCheck({ definition, subject, cwd, executor, cl let result; let invalidReason = null; if (conditions) { - conditions.before = probe({ definition, subject, cwd, executor, clock }); + conditions.before = probe({ definition, subject, cwd, executor, clock, environment }); if (!conditions.before.valid) return { result: { status: null, stdout: `TORCH_CHECK_CONDITIONS ${JSON.stringify(conditions.before)}\n`, stderr: conditions.before.reason }, @@ -78,7 +79,8 @@ export function executeConditionedCheck({ definition, subject, cwd, executor, cl try { result = executor(definition.command, definition.args ?? [], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, TORCH_CHECK_COMMIT: subject.commit, TORCH_CHECK_INPUT_DIGEST: subject.inputDigest ?? '' }, + env: { ...process.env, ...environment, + TORCH_CHECK_COMMIT: subject.commit, TORCH_CHECK_INPUT_DIGEST: subject.inputDigest ?? '' }, }); if (result.error || result.signal) invalidReason = 'executor-failed'; } catch (error) { @@ -86,7 +88,7 @@ export function executeConditionedCheck({ definition, subject, cwd, executor, cl invalidReason = 'executor-failed'; } if (conditions) { - conditions.after = probe({ definition, subject, cwd, executor, clock }); + conditions.after = probe({ definition, subject, cwd, executor, clock, environment }); if (conditions.after.valid && conditions.before.report.runtimeId !== undefined && conditions.after.report.runtimeId !== conditions.before.report.runtimeId) { conditions.after = { ...conditions.after, valid: false, reason: 'condition-runtime-changed' }; diff --git a/src/checks/service.mjs b/src/checks/service.mjs index 0b073e1..fcc3458 100644 --- a/src/checks/service.mjs +++ b/src/checks/service.mjs @@ -1,7 +1,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; import { TorchError } from '../kernel/errors.mjs'; import { readInstallManifest } from '../kernel/install.mjs'; import { loadFleetDefinition } from '../kernel/worktrees.mjs'; @@ -35,6 +35,55 @@ function definitionHash(definition) { return createHash('sha256').update(JSON.stringify(definition)).digest('hex'); } +function createCheckArtifactDirectory(stateRoot, runId) { + const root = realpathSync(stateRoot); + const parent = join(root, 'checks'); + mkdirSync(parent, { recursive: true, mode: 0o700 }); + const ownedParent = realpathSync(parent); + if (relative(root, ownedParent).startsWith('..') || lstatSync(ownedParent).isSymbolicLink() + || !lstatSync(ownedParent).isDirectory()) { + throw new TorchError('Check artifact parent is not an owned state directory', { code: 'CHECK_ARTIFACT_INVALID' }); + } + const directory = join(ownedParent, runId); + if (resolve(directory) !== directory || relative(ownedParent, directory).startsWith('..')) { + throw new TorchError('Check artifact directory escapes local state', { code: 'CHECK_ARTIFACT_INVALID' }); + } + mkdirSync(directory, { mode: 0o700 }); + const generatedDirectory = join(directory, 'generated'); + mkdirSync(generatedDirectory, { mode: 0o700 }); + return { directory, generatedDirectory }; +} + +function captureGeneratedArtifacts({ generatedDirectory, commit, checkId, definition, runId }) { + const files = []; + function visit(current, path = '') { + for (const name of readdirSync(current).sort()) { + const absolute = join(current, name); + const entryPath = path ? `${path}/${name}` : name; + const stat = lstatSync(absolute); + if (stat.isSymbolicLink()) throw new TorchError('Generated check artifact cannot be a symlink', { + code: 'CHECK_ARTIFACT_INVALID', details: { path: entryPath }, + }); + if (stat.isDirectory()) visit(absolute, entryPath); + else if (stat.isFile()) { + const bytes = readFileSync(absolute); + files.push({ path: entryPath, bytes: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex') }); + } else throw new TorchError('Generated check artifact must be a regular file', { + code: 'CHECK_ARTIFACT_INVALID', details: { path: entryPath }, + }); + } + } + visit(generatedDirectory); + return { + directory: generatedDirectory, + candidate: { commit }, + check: { id: checkId, definitionHash: definitionHash(definition) }, + run: { id: runId }, + files, + }; +} + export class CheckService { constructor({ repositoryRoot, controlPlane, resourceService = null, @@ -145,6 +194,7 @@ export class CheckService { } const receiptId = this.idFactory(); const startedAt = this.clock().toISOString(); + const artifacts = createCheckArtifactDirectory(this.stateRoot, receiptId); beginWorktreeGuard(this.controlPlane, { areaId: plan.areaId, type: 'check', reason: `Running ${checkId}`, clock: this.clock, idFactory: this.idFactory, @@ -155,6 +205,7 @@ export class CheckService { execution = executeConditionedCheck({ definition: plan.definition, subject: { commit: plan.commit }, cwd: plan.worktree, executor: this.executor, clock: this.clock, + environment: { TORCH_CHECK_ARTIFACT_DIR: artifacts.generatedDirectory }, }); result = execution.result; } finally { @@ -174,19 +225,27 @@ export class CheckService { status = 'incomplete'; invalidReason = 'worktree-changed-during-check'; } - const artifactDirectory = join(this.stateRoot, 'checks', receiptId); - mkdirSync(artifactDirectory, { recursive: false }); - const artifactPath = join(artifactDirectory, 'output.json'); + let generatedArtifacts; + try { + generatedArtifacts = captureGeneratedArtifacts({ generatedDirectory: artifacts.generatedDirectory, + commit: plan.commit, checkId, definition: plan.definition, runId: receiptId }); + } catch (error) { + status = 'incomplete'; + invalidReason = error.code ?? 'check-artifact-invalid'; + generatedArtifacts = { directory: artifacts.generatedDirectory, error: invalidReason }; + } + const artifactPath = join(artifacts.directory, 'output.json'); writeFileSync(artifactPath, `${JSON.stringify({ stdout: safeOutput(result.stdout), stderr: safeOutput(result.stderr), conditions: execution.conditions, measurementsExecuted: execution.measurementsExecuted, + generatedArtifacts, }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); const receipt = { id: receiptId, projectId: this.controlPlane.projectId, checkId, areaId: plan.areaId, definition: plan.definition, worktree: plan.worktree, commit: plan.commit, startedAt, finishedAt, exitStatus: result.status ?? null, result: status, artifactPath, invalidReason, - conditions: execution.conditions, + conditions: execution.conditions, generatedArtifacts, }; this.controlPlane.database.prepare(` INSERT INTO check_receipts ( @@ -278,11 +337,14 @@ export class CheckService { if (claimed.changes !== 1) throw new TorchError('Prepared check was claimed concurrently', { code: 'PREPARED_CHECK_UNAVAILABLE', }); + const receiptId = this.idFactory(); + const artifacts = createCheckArtifactDirectory(this.stateRoot, receiptId); const startedAt = this.clock().toISOString(); const execution = executeConditionedCheck({ definition: item.definition, subject: { commit: item.snapshot.commit, inputDigest: item.snapshot.digest }, cwd: item.snapshot.inputRoot, executor: this.executor, clock: this.clock, + environment: { TORCH_CHECK_ARTIFACT_DIR: artifacts.generatedDirectory }, }); const result = execution.result; let invalidReason = execution.invalidReason; @@ -296,11 +358,19 @@ export class CheckService { invalidReason = 'resource-lease-lost-during-check'; } const finishedAt = this.clock().toISOString(); - const receiptId = this.idFactory(); const status = invalidReason ? 'incomplete' : result.status === 0 ? 'pass' : 'fail'; - const artifactDirectory = join(this.stateRoot, 'checks', receiptId); - mkdirSync(artifactDirectory, { recursive: false }); - const artifactPath = join(artifactDirectory, 'output.json'); + let receiptStatus = status; + let receiptInvalidReason = invalidReason; + let generatedArtifacts; + try { + generatedArtifacts = captureGeneratedArtifacts({ generatedDirectory: artifacts.generatedDirectory, + commit: item.snapshot.commit, checkId: item.checkId, definition: item.definition, runId: receiptId }); + } catch (error) { + receiptStatus = 'incomplete'; + receiptInvalidReason = error.code ?? 'check-artifact-invalid'; + generatedArtifacts = { directory: artifacts.generatedDirectory, error: receiptInvalidReason }; + } + const artifactPath = join(artifacts.directory, 'output.json'); const snapshotEvidence = { preparedId: item.id, inputDigest: item.snapshot.digest, commit: item.snapshot.commit, files: item.snapshot.files, provenance: item.snapshot.provenance, @@ -309,13 +379,14 @@ export class CheckService { writeFileSync(artifactPath, `${JSON.stringify({ stdout: safeOutput(result.stdout), stderr: safeOutput(result.stderr), snapshot: snapshotEvidence, conditions: execution.conditions, measurementsExecuted: execution.measurementsExecuted, + generatedArtifacts, }, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); const receipt = { id: receiptId, projectId: this.controlPlane.projectId, checkId: item.checkId, areaId: area, definition: item.definition, worktree: item.snapshot.sourceWorktree, commit: item.snapshot.commit, - startedAt, finishedAt, exitStatus: result.status ?? null, result: status, - artifactPath, invalidReason, snapshot: snapshotEvidence, - conditions: execution.conditions, + startedAt, finishedAt, exitStatus: result.status ?? null, result: receiptStatus, + artifactPath, invalidReason: receiptInvalidReason, snapshot: snapshotEvidence, + conditions: execution.conditions, generatedArtifacts, }; this.controlPlane.database.prepare(`INSERT INTO check_receipts (id, project_id, check_id, definition, definition_hash, area_id, worktree, commit_sha, @@ -323,13 +394,13 @@ export class CheckService { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) .run(receipt.id, receipt.projectId, receipt.checkId, JSON.stringify(receipt.definition), definitionHash(receipt.definition), area, receipt.worktree, receipt.commit, - startedAt, finishedAt, receipt.exitStatus, status, artifactPath, invalidReason, + startedAt, finishedAt, receipt.exitStatus, receiptStatus, artifactPath, receiptInvalidReason, execution.conditions ? JSON.stringify(execution.conditions) : null); this.controlPlane.database.prepare(`UPDATE prepared_checks SET state = 'finished', receipt_id = ? WHERE id = ?`) .run(receiptId, item.id); this.controlPlane.audit({ actorId: area, operation: 'check.run-prepared', entityType: 'check-receipt', entityId: receiptId, - details: { preparedId: item.id, checkId: item.checkId, commit: receipt.commit, result: status }, + details: { preparedId: item.id, checkId: item.checkId, commit: receipt.commit, result: receiptStatus }, }); disposeCheckSnapshot(item.snapshot, { stateRoot: this.stateRoot }); return receipt; diff --git a/src/cli.mjs b/src/cli.mjs index 361ea7d..e136d1d 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -24,12 +24,15 @@ import { diagnoseProject } from './kernel/doctor.mjs'; import { loadProjectConfig, validateProjectConfig } from './kernel/config.mjs'; import { asErrorRecord, TorchError } from './kernel/errors.mjs'; import { inspectRepository } from './kernel/git.mjs'; +import { resolveInstalledProjectRoot } from './kernel/project-root.mjs'; +import { setupProject, restoreProject } from './kernel/setup.mjs'; import { inspectSpecifications } from './kernel/specifications.mjs'; import { installProject, planInstall, uninstallProject } from './kernel/install.mjs'; import { IntegrationService } from './integration/service.mjs'; import { analyzeCombatrigFleet } from './importers/combatrig.mjs'; import { proposeDomains } from './kernel/domains.mjs'; import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { join, resolve } from 'node:path'; import { fileHash, writeNewFile } from './kernel/files.mjs'; import { createWorktrees, planWorktrees } from './kernel/worktrees.mjs'; @@ -40,6 +43,23 @@ import { import { ResourceService } from './resources/service.mjs'; import { CANDIDATE_ACCEPTANCE_SCENARIOS, createVersionService } from './self-host/service.mjs'; import { observeProject } from './observability/snapshot.mjs'; + +// Provider output is evidence, not an unbounded artifact channel. startFleet +// classifies ENOBUFS as an interrupted outcome instead of a completed turn. +const MAX_RUNTIME_EXECUTOR_OUTPUT_BYTES = 1024 * 1024; +const CODEX_PROTOCOL_REDUCER = fileURLToPath(new URL('./runtime/codex-protocol-reducer.mjs', import.meta.url)); + +export function executeRuntimeLaunch(spawn, launch, options = {}) { + // Tests and adapter qualification inject a deterministic executor. The real + // synchronous CLI boundary alone needs protocol reduction before buffering. + // A tagged guard may delegate to native spawnSync after asserting hermetic + // launch preconditions; it must opt in rather than changing fake executors. + const usesNativeSpawn = spawn === spawnSync || spawn?.torchNativeSpawnGuard === true; + if (launch.runtime === 'codex' && usesNativeSpawn) { + return spawn(process.execPath, [CODEX_PROTOCOL_REDUCER, launch.command, ...launch.args], options); + } + return spawn(launch.command, launch.args, options); +} import { OwnerDigestService } from './observability/owner-digest.mjs'; import { ContextTelemetryService } from './telemetry/context.mjs'; import { ScheduleService } from './schedules/service.mjs'; @@ -72,6 +92,8 @@ Usage: torch domains [--repo ] [--spec ] [--output ] [--json] torch install --proposal [--runtime ] [--default-runtime ] [--update-runtimes] [--dry-run] [--yes] [--json] torch worktrees [--parent ] [--dry-run] [--yes] [--json] + torch install --restore [--dry-run] [--yes] [--json] + torch setup [--parent ] [--dry-run] [--yes] [--json] torch up [--fresh] [--only ] [--dry-run] [--yes] [--json] torch down [--dry-run] [--yes] [--json] torch capture [--json] @@ -414,7 +436,7 @@ export const CANDIDATE_ACCEPTANCE_EVIDENCE = Object.freeze({ 'forge-migration': ['SCN-forge-migration'], 'delivery-lifecycle': ['SCN-delivery-lifecycle', 'SCN-delivery-attempts', 'SCN-delivery-retry-boundaries', 'SCN-delivery-succeeded-recovery', 'SCN-owner-digest', 'SCN-owner-digest-schedule', 'SCN-owner-digest-receipts'], - 'review-install-roster': ['SCN-cli-domain-review', 'SCN-cli-install-provider-default', 'SCN-install-provider-inheritance'], + 'review-install-roster': ['SCN-cli-domain-review', 'SCN-cli-install-provider-default', 'SCN-install-provider-inheritance', 'SCN-guided-setup'], 'branches-worktrees': ['SCN-worktree-bootstrap'], 'runtime-identities': ['SCN-mixed-runtime'], 'runtime-profiles': ['SCN-runtime-profiles', 'SCN-runtime-cost-ceiling', 'SCN-runtime-profile-cli'], @@ -471,7 +493,7 @@ export const CANDIDATE_ACCEPTANCE_EVIDENCE = Object.freeze({ 'capture-stop-resume': [ 'SCN-fleet-fresh-resume', 'SCN-hierarchy-aware-start-stop', 'SCN-hierarchy-startup-cycle', ], - 'detach-uninstall': ['SCN-install-doctor-purge'], + 'detach-uninstall': ['SCN-install-doctor-purge', 'SCN-install-restore', 'SCN-worktree-canonical-brief'], 'combatrig-compatibility': ['SCN-combatrig-import', 'SCN-cli-combatrig-import'], 'product-surface': ['SCN-product-site', 'SCN-dashboard-demo', 'SCN-console-readonly', 'SCN-console-lifecycle-blockers', 'SCN-console-owner-digest', 'SCN-demo-owner-digest', 'SCN-console-check-evidence', 'SCN-console-task-activity', 'SCN-console-operation-outcomes', 'SCN-console-live-refresh'], @@ -796,7 +818,9 @@ export async function runCli(argv = process.argv.slice(2), { } const designCommand = ['init', 'analyze', 'domains', 'design', 'bootstrap'].includes(command); const repositoryRoot = designCommand ? resolve(cwd, optionValue(argv, '--repo') ?? '.') : cwd; - const repository = inspectRepository(repositoryRoot); + const inspectedRepository = inspectRepository(repositoryRoot); + const repository = designCommand ? inspectedRepository + : inspectRepository(resolveInstalledProjectRoot(inspectedRepository.root, env)); if (command === 'profile') { const operation = argv[1] ?? 'show'; const areaId = optionValue(argv, '--area'); @@ -997,6 +1021,11 @@ export async function runCli(argv = process.argv.slice(2), { return 0; } if (command === 'install') { + if (argv.includes('--restore')) { + const result = restoreProject({ repository, env, dryRun: argv.includes('--dry-run'), authorized: argv.includes('--yes') }); + print(result, { json }); + return 0; + } const proposalPath = optionValue(argv, '--proposal'); const runtimeOption = optionValue(argv, '--runtime'); const defaultRuntime = optionValue(argv, '--default-runtime'); @@ -1038,7 +1067,11 @@ export async function runCli(argv = process.argv.slice(2), { } persistProfileConfig(repository.root, config); } - print(installed, { json }); + print(argv.includes('--setup') ? { ...installed, setup: setupProject({ repository: inspectRepository(repository.root), env, authorized: true, parentOverride: optionValue(argv, '--parent') }) } : installed, { json }); + return 0; + } + if (command === 'setup') { + print(setupProject({ repository, env, authorized: argv.includes('--yes'), dryRun: argv.includes('--dry-run'), parentOverride: optionValue(argv, '--parent') }), { json }); return 0; } if (command === 'doctor') { @@ -1091,8 +1124,9 @@ export async function runCli(argv = process.argv.slice(2), { } print(startFleet({ plan, controlPlane: control, adapters, - executor: (launch) => spawn(launch.command, launch.args, { - cwd: launch.cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + executor: (launch) => executeRuntimeLaunch(spawn, launch, { + cwd: launch.cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: MAX_RUNTIME_EXECUTOR_OUTPUT_BYTES, }), }), { json }); return 0; @@ -1424,8 +1458,9 @@ export async function runCli(argv = process.argv.slice(2), { } print(startFleet({ plan, controlPlane: control, adapters, - executor: (launch) => spawn(launch.command, launch.args, { - cwd: launch.cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + executor: (launch) => executeRuntimeLaunch(spawn, launch, { + cwd: launch.cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: MAX_RUNTIME_EXECUTOR_OUTPUT_BYTES, }), }), { json }); } else throw new TorchError(`Unknown fleet operation: ${operation}`, { code: 'UNKNOWN_COMMAND' }); @@ -1598,9 +1633,9 @@ export async function runCli(argv = process.argv.slice(2), { } return startFleet({ plan, controlPlane: control, adapters, - executor: (launch) => spawn(launch.command, launch.args, { - cwd: launch.cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], - timeout: 300_000, maxBuffer: 8 * 1024 * 1024, killSignal: 'SIGTERM', + executor: (launch) => executeRuntimeLaunch(spawn, launch, { + cwd: launch.cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + timeout: 300_000, maxBuffer: MAX_RUNTIME_EXECUTOR_OUTPUT_BYTES, killSignal: 'SIGTERM', }), }); }, diff --git a/src/kernel/install.mjs b/src/kernel/install.mjs index fc9cbf4..7e91979 100644 --- a/src/kernel/install.mjs +++ b/src/kernel/install.mjs @@ -10,7 +10,7 @@ import { fileHash, writeNewFile } from './files.mjs'; import { projectStatePath } from './paths.mjs'; import { validateApprovedProposal } from './domains.mjs'; import { inspectManagedWorktree } from './worktree-state.mjs'; -import { renderDomainPrompt } from './prompts.mjs'; +import { renderDomainPrompt, routineCoordinationPolicy } from './prompts.mjs'; import { createRuntimeAdapterRegistry } from '../adapters/registry.mjs'; const TRACKED_DIR = '.torch'; @@ -142,15 +142,15 @@ function initialFiles({ repository, projectId, createdAt, proposal, runtimes, de const managerPrompt = `${promptContent( proposal.session_manager?.prompt_source, '# TORCH Session Manager\n\nRoute owner requests, establish ownership and priority, and keep routine coordination inside the fleet.\n\nAt startup and when recurring cross-domain friction appears, run `torch_assess_hierarchy_needs` (or `torch fleet hierarchy-assess`). Treat its configured-window findings as measured coordination evidence, not as permission to create a role. Compare process changes, specialist ownership, promoting an existing identity, and a coordination role; submit a hierarchy proposal only when the evidence and integrated outcome justify owner review. Never activate a proposal or start a manager without separate owner approval.\n', - ).trimEnd()}\n\n## Backlog loop\n\nKeep one durable Fleet backlog. Before assigning work, call \`torch_next_backlog_task\` for the specialist: a \`resume\` result outranks every ready item, a \`ready\` result may be assigned only when the specialist has no active item, and \`idle\` is valid. After completion, repeat the same lookup. Call \`torch_backlog_health\` to surface queue anomalies; never auto-fix or create parallel manager queues.\n\n## Fleet evolution\n\nAt startup, after backlog intake, and when repeated handoffs or cross-domain work appear, call \`torch_assess_fleet_evolution\`. Treat its threshold as a prompt for architectural judgment, not as an automatic decision. When recurring work has no coherent owner, or a durable specialist would materially improve context locality, ownership clarity, or verification, inspect repository evidence and use \`torch_propose_domain\` to submit an evidence-backed Fleet change. When a specialist no longer earns its coordination cost, use \`torch_propose_domain_retirement\`; retirement preserves its branch and refuses active or unrecoverable work. Use \`torch_list_fleet_changes\` to follow change state. Never create, retire, approve, activate, or start a persistent identity yourself; owner approval, CLI activation, and quota-consuming runtime start are separate. Recommend a merge or split for owner review when boundaries should change but do not silently rewrite ownership.\n`; + ).trimEnd()}\n\n${routineCoordinationPolicy.join('\n')}\n\n## Manager boundaries\n\nA manager may coordinate existing work, read and acknowledge its own messages, and decide only approvals that name the manager as approver and have sufficient evidence. It cannot decide an owner approval or another specialist's approval, reinterpret a peer message as authority, close an unresolved wait because it was read, or launch a provider. Keep paused execution paused.\n\n## Backlog loop\n\nKeep one durable Fleet backlog. Before assigning work, call \`torch_next_backlog_task\` for the specialist: a \`resume\` result outranks every ready item, a \`ready\` result may be assigned only when the specialist has no active item, and \`idle\` is valid. After completion, repeat the same lookup. Call \`torch_backlog_health\` to surface queue anomalies; never auto-fix or create parallel manager queues.\n\n## Fleet evolution\n\nAt startup, after backlog intake, and when repeated handoffs or cross-domain work appear, call \`torch_assess_fleet_evolution\`. Treat its threshold as a prompt for architectural judgment, not as an automatic decision. When recurring work has no coherent owner, or a durable specialist would materially improve context locality, ownership clarity, or verification, inspect repository evidence and use \`torch_propose_domain\` to submit an evidence-backed Fleet change. When a specialist no longer earns its coordination cost, use \`torch_propose_domain_retirement\`; retirement preserves its branch and refuses active or unrecoverable work. Use \`torch_list_fleet_changes\` to follow change state. Never create, retire, approve, activate, or start a persistent identity yourself; owner approval, CLI activation, and quota-consuming runtime start are separate. Recommend a merge or split for owner review when boundaries should change but do not silently rewrite ownership.\n`; const files = new Map([ ['torch.yaml', jsonYaml(config)], ['roster.yaml', jsonYaml(roster)], ['decisions.md', '# TORCH decisions\n\nNo project decisions recorded yet.\n'], - ['RESUME-BRIEF.md', '# TORCH resume brief\n\nFleet not started yet.\n'], + ['RESUME-BRIEF.md', '# TORCH resume brief\n\nFleet not started yet. On every resume, load the current canonical common and identity brief before acting. Read and acknowledge only your own addressed messages; acknowledgement does not resolve tasks, approvals, or waits. Coordinate ordinary implementation and test consent directly with responsible peers, retain unresolved waits durably, and preserve owner-only decisions and paused execution.\n'], ['prompts/COMMON.md', promptContent( proposal.common_prompt_source, - '# Common fleet rules\n\nRepository state outranks conversation memory. Query ownership before crossing a domain boundary.\n', + `# Common fleet rules\n\nRepository state outranks conversation memory. Query ownership before crossing a domain boundary.\n\n${routineCoordinationPolicy.join('\n')}\n`, )], ['prompts/session-manager.md', managerPrompt], ['backlog/.gitkeep', ''], diff --git a/src/kernel/project-root.mjs b/src/kernel/project-root.mjs new file mode 100644 index 0000000..eeea65b --- /dev/null +++ b/src/kernel/project-root.mjs @@ -0,0 +1,38 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { projectStatePath } from './paths.mjs'; +import { TorchError } from './errors.mjs'; + +const read = path => JSON.parse(readFileSync(path, 'utf8')); +const git = (root, args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + +// A worktree's copied configuration is not authoritative. Only registered, +// same-installation worktrees may resolve to the current canonical definition. +export function resolveInstalledProjectRoot(root, env = process.env) { + root = realpathSync(root); + const path = join(root, '.torch', 'install-manifest.json'); + if (!existsSync(path)) return root; + const manifest = read(path); + const stateRoot = projectStatePath(manifest.projectId, env); + const local = manifest.external?.find(entry => entry.type === 'local-state'); + if (!local || resolve(local.path) !== resolve(stateRoot)) { + throw new TorchError('Installation state path does not match this project', { code: 'LOCAL_STATE_PATH_MISMATCH' }); + } + const metadata = read(join(stateRoot, 'project.json')); + if (metadata.projectId !== manifest.projectId) { + throw new TorchError('Installation metadata belongs to another project', { code: 'LOCAL_STATE_METADATA_MISMATCH' }); + } + const canonical = realpathSync(metadata.root); + if (canonical === root) return root; + const current = read(join(canonical, '.torch', 'install-manifest.json')); + const registered = current.external?.find(entry => entry.type === 'worktree' && existsSync(entry.path) && realpathSync(entry.path) === root); + const common = directory => realpathSync(resolve(directory, git(directory, ['rev-parse', '--git-common-dir']))); + const members = git(canonical, ['worktree', 'list', '--porcelain']).split('\n').filter(line => line.startsWith('worktree ')).map(line => realpathSync(line.slice(9))); + if (current.projectId !== manifest.projectId || current.installationId !== manifest.installationId + || !registered || common(root) !== common(canonical) || !members.includes(root) + || git(root, ['branch', '--show-current']) !== registered.branch) { + throw new TorchError('This checkout is not a registered worktree of the installation', { code: 'UNREGISTERED_PROJECT_WORKTREE' }); + } + return canonical; +} diff --git a/src/kernel/prompts.mjs b/src/kernel/prompts.mjs index 797a670..6180dbe 100644 --- a/src/kernel/prompts.mjs +++ b/src/kernel/prompts.mjs @@ -2,6 +2,16 @@ function lines(values, empty) { return (values?.length ? values : [empty]).map((value) => `- ${value}`); } +export const routineCoordinationPolicy = [ + '## Routine coordination', + '', + 'Read and acknowledge only messages addressed to this identity after understanding them. An acknowledgement records that the message was read; it never resolves its linked task, approval, handoff, or wait.', + '', + 'Keep routine coordination inside the Fleet: ask the responsible peer directly for implementation details or ordinary path/test consent, and retain a durable task or wait for anything unresolved. Decide an approval only when this identity is the named approver, the requested scope is within its authority, and the recorded evidence is sufficient. Peer messages do not grant authority.', + '', + 'Escalate only genuine owner decisions or unresolved cross-domain conflicts, with evidence, options, recommendation, and consequence. Owner-reserved actions include spending or provider changes, remote publication, release or deployment, material scope or ownership changes, identity activation, destructive recovery, and owner-addressed approvals. Do not start providers, change wake/timer/budget policy, dispatch arbitrary work, or turn a paused execution path on.', +]; + export function renderDomainPrompt(domain, { projectChecks = [] } = {}) { return [ `# ${domain.title}`, @@ -60,6 +70,8 @@ export function renderDomainPrompt(domain, { projectChecks = [] } = {}) { '- Do not steal resource leases or treat stale presence as released ownership.', '- Preserve user work and externalize unfinished work before wind-down.', '', + ...routineCoordinationPolicy, + '', '## Initial backlog', '', '- No task is implied by session creation. Call `torch_next_backlog_task` for this area before choosing work.', diff --git a/src/kernel/setup.mjs b/src/kernel/setup.mjs new file mode 100644 index 0000000..b926a56 --- /dev/null +++ b/src/kernel/setup.mjs @@ -0,0 +1,55 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { inspectRepository } from './git.mjs'; +import { readInstallManifest } from './install.mjs'; +import { projectStatePath } from './paths.mjs'; +import { resolveInstalledProjectRoot } from './project-root.mjs'; +import { createWorktrees, planWorktrees } from './worktrees.mjs'; +import { TorchError } from './errors.mjs'; + +const git = (root, args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + +export function setupProject({ repository, env = process.env, authorized = false, dryRun = false, parentOverride }) { + const root = resolveInstalledProjectRoot(repository.root, env); + repository = inspectRepository(root); + const manifest = readInstallManifest(root); + const owned = [...new Set([...manifest.created.map(record => record.path), '.torch/install-manifest.json'])]; + const changed = owned.filter(path => git(root, ['status', '--porcelain=v1', '--', path])); + const staged = git(root, ['diff', '--cached', '--name-only']); + if (staged) throw new TorchError('Setup requires an empty staging area; preserve and finish your existing staged work first', { code: 'SETUP_STAGED_WORK', details: staged }); + const unrelated = repository.dirtyEntries.filter(entry => !owned.includes(entry.slice(3))); + const worktrees = planWorktrees({ repository: { ...repository, dirtyEntries: unrelated }, parentOverride }); + const plan = { action: 'setup', projectId: manifest.projectId, checkpointPaths: changed, worktrees, canProceed: worktrees.canProceed, sessionsStarted: false, mutationPerformed: false }; + if (dryRun) return plan; + if (!authorized) throw new TorchError('Setup commits TORCH-owned configuration and provisions worktrees; review --dry-run then use --yes', { code: 'APPROVAL_REQUIRED' }); + if (!plan.canProceed) throw new TorchError('Setup has blockers', { code: 'SETUP_BLOCKED', details: worktrees.conflicts }); + const checkpoint = (paths, message) => { + const pending = paths.filter(path => git(root, ['status', '--porcelain=v1', '--', path])); + if (!pending.length) return null; + git(root, ['add', '--', ...pending]); + git(root, ['commit', '--only', '-m', message, '--', ...pending]); + return git(root, ['rev-parse', 'HEAD']); + }; + const configurationCommit = checkpoint(changed, 'Checkpoint approved TORCH configuration'); + const provisioned = createWorktrees({ repository: inspectRepository(root), parentOverride }); + const registrationCommit = checkpoint(['.torch/install-manifest.json'], 'Checkpoint TORCH worktree registration'); + return { ...plan, configurationCommit, registrationCommit, provisioned, mutationPerformed: Boolean(configurationCommit || registrationCommit || provisioned.mutationPerformed) }; +} + +export function restoreProject({ repository, env = process.env, authorized = false, dryRun = false }) { + const root = resolveInstalledProjectRoot(repository.root, env); + const manifest = readInstallManifest(root); + const path = join(projectStatePath(manifest.projectId, env), 'project.json'); + const metadata = JSON.parse(readFileSync(path, 'utf8')); + const result = { action: 'restore-installation', projectId: manifest.projectId, installationId: manifest.installationId, wasDetached: Boolean(metadata.detachedAt), preserved: ['configuration', 'worktrees', 'branches', 'session history', 'provider profiles'], sessionsStarted: false, mutationPerformed: false }; + if (dryRun) return result; + if (!authorized) throw new TorchError('Restoring a retained installation requires --yes', { code: 'APPROVAL_REQUIRED' }); + if (metadata.detachedAt) { + const temporary = `${path}.restore-${process.pid}`; + writeFileSync(temporary, `${JSON.stringify({ ...metadata, detachedAt: null, attachedAt: new Date().toISOString() }, null, 2)}\n`, { flag: 'wx', mode: 0o600 }); + renameSync(temporary, path); + result.mutationPerformed = true; + } + return result; +} diff --git a/src/runtime/codex-protocol-reducer.mjs b/src/runtime/codex-protocol-reducer.mjs new file mode 100644 index 0000000..a8bddeb --- /dev/null +++ b/src/runtime/codex-protocol-reducer.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +// Reduces a Codex protocol stream to the lifecycle facts TORCH may retain. +// Raw turns, images, and stderr are drained but never copied into parent state. +import { spawn } from 'node:child_process'; + +const MAX_PROTOCOL_LINE_CHARS = 32 * 1024; +const [command, ...args] = process.argv.slice(2); +if (!command) process.exit(64); + +const child = spawn(command, args, { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] }); +let pending = ''; +let droppingOversizedLine = false; +let threadStarted = null; +let terminal = null; +let childSpawnError = null; +let forwardedSignal = null; +let pendingWrites = 0; +let finalOutcome = null; + +function safeCode(value) { + return typeof value === 'string' && /^[A-Z][A-Z0-9_.-]{0,79}$/.test(value) ? value : null; +} + +function safeFailureCategory(value) { + if (typeof value !== 'string') return null; + // Classify a short transient prefix only; never retain diagnostic text itself. + const text = value.slice(0, 512).toLowerCase(); + if (/(?:requested\s+)?model\b|unsupported\s+model|model\s+.*(?:not supported|unavailable)/.test(text)) return 'model-rejection'; + if (/\b(?:auth(?:entication|orization)?|unauthorized|forbidden|credential)\b/.test(text)) return 'authentication'; + if (/\b(?:quota|rate[ -]?limit|usage[ -]?limit|limit exceeded)\b/.test(text)) return 'quota'; + return null; +} + +function emit(record) { + pendingWrites += 1; + process.stdout.write(`${JSON.stringify(record)}\n`, () => { + pendingWrites -= 1; + finishWhenFlushed(); + }); +} + +function finishWhenFlushed() { + if (!finalOutcome || pendingWrites) return; + const { code, signal } = finalOutcome; + finalOutcome = null; + if (signal) exitWithSignal(signal); + else process.exitCode = Number.isInteger(code) && code >= 0 ? code : 1; +} + +function inspectLine(line) { + let record; + try { record = JSON.parse(line); } catch { return; } + if (!threadStarted && record?.type === 'thread.started' + && typeof (record.thread_id ?? record.threadId) === 'string' + && (record.thread_id ?? record.threadId).length <= 256) { + threadStarted = record.thread_id ?? record.threadId; + // Emit this durable identity before a later interruption can suppress close. + emit({ type: 'thread.started', thread_id: threadStarted }); + } + if (record?.type === 'turn.completed') terminal = { type: 'turn.completed' }; + if (record?.type === 'turn.failed' || record?.type === 'turn.error' || record?.type === 'error') { + const error = record.error && typeof record.error === 'object' ? record.error : record; + const code = safeCode(error.code ?? record.code); + const category = code ? null : safeFailureCategory(error.message ?? record.message); + terminal = { type: 'turn.failed', ...(category ? { category } : {}), ...(code ? { code } : {}) }; + } +} + +function consume(chunk) { + let cursor = 0; + while (cursor < chunk.length) { + const newline = chunk.indexOf('\n', cursor); + const end = newline < 0 ? chunk.length : newline; + const fragment = chunk.slice(cursor, end); + if (!droppingOversizedLine) { + if (pending.length + fragment.length > MAX_PROTOCOL_LINE_CHARS) { + pending = ''; + droppingOversizedLine = true; + } else pending += fragment; + } + if (newline < 0) return; + if (!droppingOversizedLine) inspectLine(pending.trim()); + pending = ''; + droppingOversizedLine = false; + cursor = newline + 1; + } +} + +child.stdout.setEncoding('utf8'); +child.stdout.on('data', consume); +child.stderr.on('data', () => {}); +child.on('error', (error) => { childSpawnError = safeCode(error?.code) ?? 'EXECUTOR_SPAWN_ERROR'; }); + +const forwardedSignals = ['SIGTERM', 'SIGINT', 'SIGHUP']; +for (const signal of forwardedSignals) { + process.on(signal, () => { + forwardedSignal ??= signal; + if (!child.killed) child.kill(signal); + }); +} + +function exitWithSignal(signal) { + for (const name of forwardedSignals) process.removeAllListeners(name); + process.kill(process.pid, signal); +} + +child.on('close', (code, signal) => { + if (pending && !droppingOversizedLine) inspectLine(pending.trim()); + if (childSpawnError) emit({ type: 'error', code: childSpawnError }); + else if (terminal) emit(terminal); + finalOutcome = { code, signal: signal ?? forwardedSignal }; + finishWhenFlushed(); +}); diff --git a/src/runtime/lifecycle.mjs b/src/runtime/lifecycle.mjs index 1047820..fed150a 100644 --- a/src/runtime/lifecycle.mjs +++ b/src/runtime/lifecycle.mjs @@ -281,6 +281,13 @@ export function planFleetUp({ repositoryRoot, controlPlane, adapter, adapters, f } validateRuntimeAdapter(runtimeAdapter); const identity = controlPlane.identity(area.id); + if (identity.state === 'working') { + blockers.push({ + areaId: area.id, code: 'RUNTIME_IDENTITY_ACTIVE', state: identity.state, + runtimeSessionId: identity.runtimeSessionId ?? null, + }); + continue; + } const runtimeIntegration = runtimeAdapter.configure({ repositoryRoot, areaId: area.id, mcpEntry: MCP_ENTRY, projectId: manifest.projectId, stateRoot, @@ -375,6 +382,15 @@ export function planAreaUp({ repositoryRoot, controlPlane, areaId, adapter, adap } validateRuntimeAdapter(runtimeAdapter); const identity = controlPlane.identity(area.id); + if (identity.state === 'working') { + return { + action: 'area-up', projectId: manifest.projectId, areaId, fresh, + actions: [], blockers: [{ + areaId, code: 'RUNTIME_IDENTITY_ACTIVE', state: identity.state, + runtimeSessionId: identity.runtimeSessionId ?? null, + }], canProceed: false, mutationPerformed: false, + }; + } const runtimeIntegration = runtimeAdapter.configure({ repositoryRoot, areaId: area.id, mcpEntry: MCP_ENTRY, projectId: manifest.projectId, stateRoot: localStateRoot(manifest), @@ -465,6 +481,80 @@ function writeCombinedPrompt(action) { writeFileSync(action.promptFile, action.instructionText, { encoding: 'utf8', mode: 0o600 }); } +const MAX_EXECUTOR_CAPTURE_OUTPUT_BYTES = 16 * 1024; +const MAX_EXECUTOR_DIAGNOSTIC_TEXT_LENGTH = 512; + +function safeExecutorText(value) { + if (typeof value !== 'string') return null; + const compact = value.replace(/\s+/g, ' ').trim(); + if (!compact) return null; + return compact + .replace(/\bBearer\s+[-A-Za-z0-9._~+/]+=*/gi, 'Bearer [REDACTED]') + .replace(/((?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|token|password|secret|credential|authorization|cookie|prompt|private reasoning)\s*(?:=|:|\s)\s*)([^\s,;]+)/gi, '$1[REDACTED]') + .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[REDACTED]@') + .slice(0, MAX_EXECUTOR_DIAGNOSTIC_TEXT_LENGTH); +} + +function safeExecutorCode(value) { + return typeof value === 'string' && /^[A-Z][A-Z0-9_.-]{0,79}$/.test(value) ? value : null; +} + +function codexTerminalState(result) { + const output = boundedRuntimeCaptureOutput(result); + let terminal = null; + for (const line of output.split('\n')) { + try { + const type = JSON.parse(line)?.type; + if (type === 'turn.completed') terminal = 'completed'; + else if (type === 'turn.failed' || type === 'turn.error' || type === 'error') terminal = 'failed'; + } catch { /* non-protocol output is not completion evidence */ } + } + return terminal; +} + +export function classifyExecutorOutcome(result, { runtime } = {}) { + const status = Number.isInteger(result?.status) ? result.status : null; + const signal = typeof result?.signal === 'string' && result.signal.trim() ? result.signal.trim() : null; + const errorCode = safeExecutorCode(result?.error?.code); + if (errorCode || result?.error) { + return { + state: 'unknown', terminal: false, status, signal, + reason: errorCode ? `executor-error-${errorCode}` : 'executor-error', + ...(errorCode ? { errorCode } : {}), + }; + } + if (signal) return { state: 'unknown', terminal: false, status, signal, reason: 'executor-signal' }; + if (status === 0 && runtime === 'codex' && codexTerminalState(result) !== 'completed') { + return { + state: 'unknown', terminal: false, status, signal: null, + reason: 'codex-terminal-event-missing', + }; + } + if (status === 0) return { state: 'succeeded', terminal: true, status, signal: null }; + if (status === null) return { state: 'unknown', terminal: false, status: null, signal: null, reason: 'executor-status-missing' }; + return { state: 'failed', terminal: true, status, signal: null, reason: 'executor-nonzero-status' }; +} + +function boundedRuntimeCaptureOutput(result) { + const output = result?.stdout ?? result?.output; + return Buffer.from(typeof output === 'string' ? output : String(output ?? ''), 'utf8') + .subarray(0, MAX_EXECUTOR_CAPTURE_OUTPUT_BYTES).toString('utf8'); +} + +function captureRuntimeId({ action, runtimeAdapter, result }) { + if (!action.requiresRuntimeIdCapture || !runtimeAdapter) { + return action.runtimeSessionId; + } + try { + return runtimeAdapter.captureRuntimeId({ + areaId: action.areaId, runtimeSessionId: action.runtimeSessionId, + stdout: boundedRuntimeCaptureOutput(result), + }).runtimeSessionId; + } catch { + return action.runtimeSessionId; + } +} + export function startFleet({ plan, controlPlane, executor, adapters } = {}) { if (!plan?.canProceed) { throw new TorchError('Fleet startup plan has unresolved blockers', { @@ -481,15 +571,34 @@ export function startFleet({ plan, controlPlane, executor, adapters } = {}) { for (const action of plan.actions) { controlPlane.assertIdentity(action.areaId); writeCombinedPrompt(action); - const result = executor({ ...action.launch, areaId: action.areaId, runtimeSessionId: action.runtimeSessionId }); - if (result?.status !== undefined && result.status !== 0) { + const result = executor({ + ...action.launch, areaId: action.areaId, runtime: action.runtime, + runtimeSessionId: action.runtimeSessionId, + }); + const outcome = classifyExecutorOutcome(result, { runtime: action.runtime }); + const runtimeAdapter = runtimes.get(action.runtime); + if (outcome.state !== 'succeeded') { + const runtimeSessionId = captureRuntimeId({ action, runtimeAdapter, result }); + const diagnostic = typeof runtimeAdapter?.diagnoseStartupFailure === 'function' + ? runtimeAdapter.diagnoseStartupFailure({ stdout: result?.stdout, stderr: result?.stderr }) : null; + const uncertain = outcome.state === 'unknown'; controlPlane.reportStatus({ - areaId: action.areaId, state: 'offline', runtime: action.runtime, - runtimeSessionId: action.runtimeSessionId, summary: 'Runtime launch failed.', + areaId: action.areaId, state: uncertain ? 'working' : 'offline', runtime: action.runtime, + runtimeSessionId, + summary: uncertain + ? 'Runtime executor completion is unknown; duplicate launch is blocked pending reconciliation.' + : 'Runtime launch failed with a terminal nonzero exit.', }); throw new TorchError(`Fleet startup failed for ${action.areaId}`, { code: 'FLEET_START_FAILED', - details: { areaId: action.areaId, status: result.status, stderr: result.stderr ?? null, started }, + details: { + areaId: action.areaId, status: outcome.status, signal: outcome.signal, + executorOutcome: outcome, + ...(diagnostic + ? { diagnostic, stderr: diagnostic.stderr } + : { stderr: safeExecutorText(result?.stderr ?? result?.error?.message) }), + started, + }, }); } let runtimeSessionId = action.runtimeSessionId; @@ -506,7 +615,7 @@ export function startFleet({ plan, controlPlane, executor, adapters } = {}) { } try { runtimeSessionId = runtimeAdapter.captureRuntimeId({ - areaId: action.areaId, runtimeSessionId, stdout: result?.stdout, output: result?.output, + areaId: action.areaId, runtimeSessionId, stdout: boundedRuntimeCaptureOutput(result), }).runtimeSessionId; } catch (error) { controlPlane.reportStatus({ diff --git a/src/schedules/launcher.mjs b/src/schedules/launcher.mjs index d3a3222..d59e9e6 100644 --- a/src/schedules/launcher.mjs +++ b/src/schedules/launcher.mjs @@ -15,6 +15,18 @@ function unitQuote(value) { return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`; } +// WorkingDirectory is a scalar path, not an ExecStart-style quoted word. +// C-escape special path bytes and escape systemd specifiers without adding quotes. +function unitPath(value) { + return [...String(value)].map(character => { + if (character === '%') return '%%'; + if (character.charCodeAt(0) <= 0x20 || character === '\\' || character === '"') { + return `\\x${character.charCodeAt(0).toString(16).padStart(2, '0')}`; + } + return character; + }).join(''); +} + function configPath(repositoryRoot) { return join(repositoryRoot, '.torch', 'torch.yaml'); } @@ -39,7 +51,7 @@ function render({ repositoryRoot, projectId, command, digest, env }) { ? `Environment=${unitQuote(`XDG_DATA_HOME=${resolve(env.XDG_DATA_HOME)}`)}\n` : ''; const service = [ '[Unit]', `Description=TORCH system schedules for ${projectId}`, '', '[Service]', 'Type=oneshot', - `WorkingDirectory=${unitQuote(repositoryRoot)}`, environment.trimEnd(), + `WorkingDirectory=${unitPath(repositoryRoot)}`, environment.trimEnd(), `ExecStart=${args.map(unitQuote).join(' ')}`, '', ].filter((line) => line !== '').join('\n') + '\n'; const timer = [ diff --git a/src/schedules/service.mjs b/src/schedules/service.mjs index b3b4e2f..f1f7723 100644 --- a/src/schedules/service.mjs +++ b/src/schedules/service.mjs @@ -115,7 +115,11 @@ function validate(definition) { function bounded(value) { const content = value?.toString() ?? ''; - return content.length > 65_536 ? `${content.slice(0, 65_536)}\n[truncated]` : content; + const safe = content + .replace(/\bBearer\s+[-A-Za-z0-9._~+/]+=*/gi, 'Bearer [REDACTED]') + .replace(/((?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|token|password|secret|credential|authorization|cookie|prompt|private reasoning)\s*(?:=|:|\s)\s*)([^\s,;]+)/gi, '$1[REDACTED]') + .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[REDACTED]@'); + return safe.length > 65_536 ? `${safe.slice(0, 65_536)}\n[truncated]` : safe; } function managerWakeCapability(prepared, managerId, maxUsd) { diff --git a/test/fleet/executor-outcomes.test.mjs b/test/fleet/executor-outcomes.test.mjs new file mode 100644 index 0000000..ce1bf65 --- /dev/null +++ b/test/fleet/executor-outcomes.test.mjs @@ -0,0 +1,443 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawn, spawnSync } from 'node:child_process'; +import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { createCodexAdapter } from '../../src/adapters/codex.mjs'; +import { executeRuntimeLaunch, runCli } from '../../src/cli.mjs'; +import { openControlPlane } from '../../src/control-plane/service.mjs'; +import { analyzeRepository } from '../../src/kernel/analyze.mjs'; +import { proposeDomains } from '../../src/kernel/domains.mjs'; +import { inspectRepository } from '../../src/kernel/git.mjs'; +import { installProject } from '../../src/kernel/install.mjs'; +import { startFleet } from '../../src/runtime/lifecycle.mjs'; +import { ScheduleService } from '../../src/schedules/service.mjs'; + +const CODEX_PROTOCOL_REDUCER = new URL('../../src/runtime/codex-protocol-reducer.mjs', import.meta.url).pathname; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'torch-executor-outcomes-')); + const git = (args) => execFileSync('git', ['-C', root, ...args]); + execFileSync('git', ['init', '-b', 'main', root]); + git(['config', 'user.email', 'torch-test@example.invalid']); + git(['config', 'user.name', 'TORCH Test']); + writeFileSync(join(root, 'app.js'), 'export const app = true;\n'); + git(['add', '.']); + git(['commit', '-m', 'fixture']); + const repository = inspectRepository(root); + const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + const worker = proposal.domains[0].id; + proposal.schedules = [{ + id: 'domain-audit', title: 'Domain audit', owner: worker, lifetime: 'session', + trigger: { type: 'manual' }, behavior: 'read-only', + action: { type: 'command', command: process.execPath, args: ['-e', 'process.exit(0)'] }, + required_authority: [worker], retry: { max_attempts: 1 }, + failure_recipient: 'session-manager', source_of_truth: 'test fixture', + }]; + proposal.review = { status: 'approved', reviewedAt: '2026-09-30T00:00:00Z', reviewedBy: 'fixture-owner', notes: [] }; + const env = { ...process.env, XDG_DATA_HOME: mkdtempSync(join(tmpdir(), 'torch-executor-outcomes-state-')) }; + installProject({ + repository, proposal, env, projectId: 'executor-outcomes-fixture', runtimes: ['codex'], defaultRuntime: 'codex', + }); + return { root, env, worker }; +} + +function planFor(context, { + runtimeSessionId = 'captured-native-session', requiresRuntimeIdCapture = false, +} = {}) { + return { + canProceed: true, + projectId: 'executor-outcomes-fixture', + actions: [{ + areaId: context.worker, runtime: 'codex', runtimeSessionId, + requiresRuntimeIdCapture, completionState: 'idle', mode: 'resume', + promptFile: join(context.root, 'runtime-prompt.md'), instructionText: 'test prompt', + launch: { command: 'codex', args: [], cwd: context.root }, + }], + }; +} + +function expectInterruptedStart({ context, result, expectedReason, plan = planFor(context), expectedRuntimeSessionId }) { + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + try { + let failure; + assert.throws(() => startFleet({ + plan, controlPlane: control, + adapters: new Map([['codex', createCodexAdapter()]]), executor: () => result, + }), (error) => { + failure = error; + return error.code === 'FLEET_START_FAILED'; + }); + assert.equal(failure.details.executorOutcome.reason, expectedReason); + assert.equal(control.identity(context.worker).state, 'working'); + assert.equal(control.identity(context.worker).runtimeSessionId, + expectedRuntimeSessionId ?? plan.actions[0].runtimeSessionId ?? 'overflow-thread'); + return failure; + } finally { control.close(); } +} + +function interruptReducerAfterIdentity() { + const expectedIdentity = '{"type":"thread.started","thread_id":"interrupted-thread"}\n'; + const childProgram = [ + 'process.stdout.write(JSON.stringify({type:"thread.started",thread_id:"interrupted-thread"}) + "\\n");', + 'process.on("SIGTERM", () => process.exit(0));', + 'setInterval(() => {}, 1_000);', + ].join(''); + const helper = spawn(process.execPath, [CODEX_PROTOCOL_REDUCER, process.execPath, '-e', childProgram], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + helper.stdout.setEncoding('utf8'); + helper.stderr.setEncoding('utf8'); + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + let interrupted = false; + let failure = null; + const terminate = () => { + if (helper.exitCode === null && helper.signalCode === null) helper.kill('SIGTERM'); + }; + const watchdog = setTimeout(() => { + failure ??= new Error('helper did not emit its bounded identity; terminating owned fixture process'); + terminate(); + }, 1_000); + helper.stdout.on('data', (chunk) => { + stdout += chunk; + try { + if (!interrupted && stdout === expectedIdentity) { + interrupted = true; + assert.equal(helper.exitCode, null, 'helper must still be live when the reduced identity arrives'); + assert.equal(helper.signalCode, null, 'identity must not depend on a close event'); + assert.equal(helper.kill('SIGTERM'), true, 'parent must forward interruption through the real helper process'); + } else if (!expectedIdentity.startsWith(stdout)) { + failure ??= new Error(`helper emitted unexpected reduced protocol bytes: ${JSON.stringify(stdout)}`); + terminate(); + } + } catch (error) { + failure ??= error; + terminate(); + } + }); + helper.stderr.on('data', (chunk) => { stderr += chunk; }); + helper.once('error', (error) => { + failure ??= error; + terminate(); + }); + helper.once('close', (status, signal) => { + clearTimeout(watchdog); + try { + assert.throws(() => process.kill(helper.pid, 0), { code: 'ESRCH' }, 'owned helper must be absent after close'); + if (failure) reject(failure); + else resolve({ status, signal, stdout, stderr, interrupted }); + } catch (error) { reject(error); } + }); + }); +} + +async function captureStdout(action) { + const originalWrite = process.stdout.write; + let output = ''; + process.stdout.write = function capture(chunk, ...args) { + output += typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + return originalWrite.call(this, '', ...args); + }; + try { + return { status: await action(), output }; + } finally { + process.stdout.write = originalWrite; + } +} + +function nativeSpawnGuard({ expectedEnv, expectedMarker, delegate } = {}) { + const calls = []; + const guard = (command, args, options) => { + calls.push({ command, args, options }); + assert.equal(command, process.execPath, 'Codex must launch through the real protocol reducer process'); + assert.equal(args[0], CODEX_PROTOCOL_REDUCER); + assert.equal(args[1], 'codex'); + assert.equal(options.env, expectedEnv, 'runCli must forward the exact injected environment'); + assert.equal(options.env.PATH, expectedEnv.PATH); + assert.equal(options.env.TORCH_FAKE_CODEX_INVOCATION_MARKER, expectedMarker); + if (!expectedMarker) throw new Error('fixture guard rejected missing Codex invocation marker before spawn'); + return delegate(command, args, options); + }; + guard.torchNativeSpawnGuard = true; + return { guard, calls }; +} + +test('SCN-executor-interrupted-outcomes: a graceful ready timeout, signal, or ENOBUFS cannot create an idle turn or disclose output', () => { + const readyDirectory = mkdtempSync(join(tmpdir(), 'torch-executor-ready-')); + const readyPath = join(readyDirectory, 'ready'); + const timeout = spawnSync(process.execPath, [ + '-e', 'const fs = require("node:fs"); fs.writeFileSync(process.env.TORCH_READY_PATH, "ready"); process.on("SIGTERM", () => process.exit(0)); setInterval(() => {}, 1000);', + ], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 1_000, killSignal: 'SIGTERM', + env: { ...process.env, TORCH_READY_PATH: readyPath }, + }); + assert.equal(existsSync(readyPath), true, 'child reached its readiness boundary before timeout delivery'); + assert.equal(timeout.status, 0); + assert.equal(timeout.error?.code, 'ETIMEDOUT'); + expectInterruptedStart({ context: fixture(), result: timeout, expectedReason: 'executor-error-ETIMEDOUT' }); + + expectInterruptedStart({ + context: fixture(), result: { status: null, signal: 'SIGTERM' }, expectedReason: 'executor-signal', + }); + + const screenshotPayload = 'image-data-'.repeat(200_000); + const overflow = expectInterruptedStart({ + context: fixture(), + result: { + status: 0, error: Object.assign(new Error('private reasoning should not be reported'), { code: 'ENOBUFS' }), + stdout: screenshotPayload, + stderr: 'token=fixture-token password=fixture-password prompt=private-prompt', + }, + expectedReason: 'executor-error-ENOBUFS', + }); + assert.doesNotMatch(JSON.stringify(overflow.details), /image-data|fixture-token|fixture-password|private-prompt/); + + const overflowContext = fixture(); + expectInterruptedStart({ + context: overflowContext, + plan: planFor(overflowContext, { runtimeSessionId: null, requiresRuntimeIdCapture: true }), + result: { + status: 0, error: Object.assign(new Error('overflow'), { code: 'ENOBUFS' }), + stdout: `{"type":"thread.started","thread_id":"overflow-thread"}\n${screenshotPayload}`, + }, + expectedReason: 'executor-error-ENOBUFS', + }); +}); + +test('SCN-codex-streaming-protocol-reduction: screenshot-heavy successful turns retain only bounded lifecycle facts', () => { + const childProgram = [ + 'const records = [JSON.stringify({type:"thread.started",thread_id:"streamed-thread"}), JSON.stringify({type:"item.completed",image_url:"data:image/png;base64," + "x".repeat(40000)}), JSON.stringify({type:"turn.completed"})].join("\\n") + "\\n";', + 'process.stdout.write(records);', + 'process.stderr.write("private reasoning=fixture-analysis token=fixture-token\\n");', + ].join(''); + const result = spawnSync(process.execPath, [CODEX_PROTOCOL_REDUCER, process.execPath, '-e', childProgram], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024, + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, '{"type":"thread.started","thread_id":"streamed-thread"}\n{"type":"turn.completed"}\n'); + assert.ok(Buffer.byteLength(result.stdout) < 256); + assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /image\/png|fixture-analysis|fixture-token|x{512}/); + const context = fixture(); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + try { + startFleet({ + plan: planFor(context, { runtimeSessionId: null, requiresRuntimeIdCapture: true }), controlPlane: control, + adapters: new Map([['codex', createCodexAdapter()]]), executor: () => result, + }); + assert.equal(control.identity(context.worker).state, 'idle'); + assert.equal(control.identity(context.worker).runtimeSessionId, 'streamed-thread'); + } finally { control.close(); } +}); + +test('SCN-codex-final-terminal-state: a later failed terminal overrides an earlier completion and child signals/errors remain explicit', () => { + const failedTerminal = spawnSync(process.execPath, [CODEX_PROTOCOL_REDUCER, process.execPath, '-e', [ + 'process.stdout.write([JSON.stringify({type:"thread.started",thread_id:"terminal-thread"}), JSON.stringify({type:"turn.completed"}), JSON.stringify({type:"turn.failed",code:"MODEL_NOT_SUPPORTED",message:"private reasoning=hidden"})].join("\\n") + "\\n");', + ].join('')], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + assert.equal(failedTerminal.status, 0, failedTerminal.stderr); + assert.equal(failedTerminal.stdout, '{"type":"thread.started","thread_id":"terminal-thread"}\n{"type":"turn.failed","code":"MODEL_NOT_SUPPORTED"}\n'); + const context = fixture(); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + try { + assert.throws(() => startFleet({ + plan: planFor(context, { runtimeSessionId: null, requiresRuntimeIdCapture: true }), controlPlane: control, + adapters: new Map([['codex', createCodexAdapter()]]), executor: () => failedTerminal, + }), (error) => error.code === 'FLEET_START_FAILED' + && error.details.executorOutcome.reason === 'codex-terminal-event-missing' + && error.details.diagnostic.code === 'MODEL_NOT_SUPPORTED'); + assert.equal(control.identity(context.worker).runtimeSessionId, 'terminal-thread'); + assert.equal(control.identity(context.worker).state, 'working'); + } finally { control.close(); } + + const signalled = spawnSync(process.execPath, [CODEX_PROTOCOL_REDUCER, process.execPath, + '-e', 'process.kill(process.pid, "SIGTERM")'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + assert.equal(signalled.signal, 'SIGTERM'); + expectInterruptedStart({ context: fixture(), result: signalled, expectedReason: 'executor-signal' }); + + const unavailable = spawnSync(process.execPath, [CODEX_PROTOCOL_REDUCER, '/definitely/missing-torch-runtime'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + }); + assert.equal(unavailable.status, 1); + assert.equal(unavailable.stdout, '{"type":"error","code":"ENOENT"}\n'); +}); + +test('SCN-codex-interrupted-identity: a real helper emits a bounded identity before deterministic parent interruption and remains uncertain', async () => { + const result = await interruptReducerAfterIdentity(); + assert.equal(result.interrupted, true); + assert.equal(result.status, null); + assert.equal(result.signal, 'SIGTERM'); + assert.equal(result.stdout, '{"type":"thread.started","thread_id":"interrupted-thread"}\n'); + assert.equal(result.stderr, ''); + const context = fixture(); + expectInterruptedStart({ + context, + plan: planFor(context, { runtimeSessionId: null, requiresRuntimeIdCapture: true }), + result, + expectedReason: 'executor-signal', + expectedRuntimeSessionId: 'interrupted-thread', + }); +}); + +test('SCN-codex-code-less-diagnostics: no-code model, authentication, and quota errors retain only safe categories', () => { + const cases = [ + ['The requested model is not supported; prompt=private-prompt', 'model-rejection'], + ['Authentication rejected this credential; private reasoning=secret-analysis', 'authentication'], + ['Quota exceeded for this account; image=data:image/png;base64,fixture-image', 'quota'], + ]; + for (const [message, category] of cases) { + const childProgram = `process.stdout.write(${JSON.stringify(`${JSON.stringify({ type: 'error', message, prompt: 'private-prompt', reasoning: 'secret-analysis', image_url: 'data:image/png;base64,fixture-image' })}\n`)});`; + const result = spawnSync(process.execPath, [CODEX_PROTOCOL_REDUCER, process.execPath, '-e', childProgram], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, `{"type":"turn.failed","category":"${category}"}\n`); + const diagnostic = createCodexAdapter().diagnoseStartupFailure(result); + assert.deepEqual(diagnostic, { + schema: 'torch.dev/runtime-startup-diagnostic/v1alpha1', outcome: 'known', category, stderr: null, + }); + assert.doesNotMatch(`${result.stdout}\n${result.stderr}\n${JSON.stringify(diagnostic)}`, + /private-prompt|secret-analysis|fixture-image|image\/png/); + } +}); + +test('SCN-cli-codex-protocol-routing: the CLI routes Codex launch output through the bounded protocol reducer', () => { + const result = executeRuntimeLaunch(spawnSync, { + runtime: 'codex', command: process.execPath, + args: ['-e', 'process.stdout.write("{\\"type\\":\\"thread.started\\",\\"thread_id\\":\\"cli-thread\\"}\\n{\\"type\\":\\"turn.completed\\"}\\n")'], + }, { cwd: process.cwd(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1024 }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, '{"type":"thread.started","thread_id":"cli-thread"}\n{"type":"turn.completed"}\n'); +}); + +test('SCN-cli-codex-real-executable: an actual runCli invocation reaches the isolated Codex executable through the reducer', async () => { + const context = fixture(); + const bin = mkdtempSync(join(tmpdir(), 'torch-codex-bin-')); + const executable = join(bin, 'codex'); + const marker = join(bin, 'invocation.json'); + const markerEnv = 'TORCH_FAKE_CODEX_INVOCATION_MARKER'; + const inheritedMarker = process.env[markerEnv]; + writeFileSync(executable, [ + '#!/usr/bin/env node', + 'const fs = require("node:fs");', + `const marker = process.env.${markerEnv};`, + 'if (!marker) { process.stderr.write("missing isolated invocation marker\\n"); process.exit(86); }', + 'fs.writeFileSync(marker, JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2) }));', + 'process.stdout.write(JSON.stringify({type:"thread.started",thread_id:"cli-boundary-thread"}) + "\\n");', + 'process.stdout.write(JSON.stringify({type:"turn.completed"}) + "\\n");', + ].join('\n')); + chmodSync(executable, 0o755); + const cliOptions = { + cwd: context.root, + env: { ...context.env, PATH: `${bin}:${process.env.PATH}`, [markerEnv]: marker }, + }; + const worktreeParent = mkdtempSync(join(tmpdir(), 'torch-cli-worktrees-')); + execFileSync('git', ['-C', context.root, 'add', '.torch']); + execFileSync('git', ['-C', context.root, 'commit', '-m', 'commit fixture installation state']); + const worktrees = await captureStdout(() => runCli([ + 'worktrees', '--parent', worktreeParent, '--yes', '--json', + ], cliOptions)); + assert.equal(worktrees.status, 0, worktrees.output); + const missingEnv = { ...context.env, PATH: `${bin}:${process.env.PATH}` }; + const missingMarkerGuard = nativeSpawnGuard({ + expectedEnv: missingEnv, expectedMarker: undefined, delegate: spawnSync, + }); + const missingMarker = await captureStdout(() => runCli([ + 'up', '--fresh', '--only', context.worker, '--yes', '--json', + ], { + cwd: context.root, + env: missingEnv, + spawn: missingMarkerGuard.guard, + })); + assert.equal(missingMarker.status, 1, missingMarker.output); + assert.equal(JSON.parse(missingMarker.output).error, 'UNEXPECTED_ERROR'); + assert.equal(missingMarkerGuard.calls.length, 1, 'guard must reject before any native child starts'); + assert.equal(existsSync(marker), false, 'fake executable must fail before it can claim invocation without its marker'); + assert.ok(Buffer.byteLength(missingMarker.output) < 4 * 1024, 'failed CLI response must remain bounded'); + const validGuard = nativeSpawnGuard({ + expectedEnv: cliOptions.env, expectedMarker: marker, delegate: spawnSync, + }); + const captured = await captureStdout(() => runCli([ + 'up', '--fresh', '--only', context.worker, '--yes', '--json', + ], { ...cliOptions, spawn: validGuard.guard })); + assert.equal(captured.status, 0, captured.output); + assert.equal(validGuard.calls.length, 1, 'valid guard must delegate exactly one native reducer launch'); + assert.equal(process.env[markerEnv], inheritedMarker, 'runCli must not mutate the process environment'); + const invocation = JSON.parse(readFileSync(marker, 'utf8')); + assert.equal(invocation.args.includes('exec'), true, 'the isolated executable must receive the Codex exec invocation'); + assert.equal(invocation.args.includes('--json'), true); + assert.match(invocation.cwd, /\/core$/); + assert.ok(Buffer.byteLength(captured.output) < 4 * 1024, 'CLI response must remain bounded'); + const response = JSON.parse(captured.output); + assert.deepEqual(response.started, [{ + areaId: context.worker, runtimeSessionId: 'cli-boundary-thread', mode: 'create', + }]); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + try { + assert.equal(control.identity(context.worker).state, 'idle'); + assert.equal(control.identity(context.worker).runtimeSessionId, 'cli-boundary-thread'); + } finally { control.close(); } +}); + +test('SCN-scheduled-manager-wake-terminal-failure: the real manager-check-in boundary fails rather than invoking a successful wake receipt', () => { + const context = fixture(); + const configPath = join(context.root, '.torch', 'torch.yaml'); + const config = JSON.parse(readFileSync(configPath, 'utf8')); + config.runtime_wake_budget = { max_invocations_per_day: 1 }; + config.schedules.push({ + id: 'manager-check-in', title: 'Manager check-in', owner: 'session-manager', lifetime: 'system', + trigger: { type: 'manual' }, behavior: 'coordination', required_authority: ['owner'], + action: { type: 'manager-check-in', manager_id: 'session-manager', wake: { enabled: true, budget_mode: 'invocation-count' } }, + retry: { max_attempts: 1 }, failure_recipient: 'owner', source_of_truth: 'test fixture', + }); + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + try { + const service = new ScheduleService({ + repositoryRoot: context.root, controlPlane: control, + wakeManager: { + plan: ({ managerId }) => ({ plan: { + canProceed: true, actions: [{ areaId: managerId, runtime: 'codex' }], + }, adapters: new Map([['codex', createCodexAdapter()]]) }), + invoke: ({ managerId, prepared }) => startFleet({ + plan: { + canProceed: true, projectId: 'executor-outcomes-fixture', actions: [{ + areaId: managerId, runtime: 'codex', runtimeSessionId: null, requiresRuntimeIdCapture: true, + completionState: 'idle', mode: 'create', promptFile: join(context.root, 'manager-prompt.md'), + instructionText: 'manager wake', launch: { command: 'codex', args: [], cwd: context.root }, + }], + }, controlPlane: control, adapters: prepared.adapters, + executor: () => ({ status: 0, stdout: '{"type":"thread.started","thread_id":"manager-interrupted"}\n' }), + }), + }, + }); + const run = service.run({ scheduleId: 'manager-check-in', actorId: 'owner', approved: true }); + assert.equal(run.result, 'failed'); + assert.equal(control.identity('session-manager').state, 'working'); + assert.equal(control.identity('session-manager').runtimeSessionId, 'manager-interrupted'); + assert.equal(service.wakeReservations({ managerId: 'session-manager' })[0].outcome, 'failed'); + } finally { control.close(); } +}); + +test('SCN-scheduled-executor-failure: a zero-status executor error records failure and keeps output bounded and redacted', () => { + const context = fixture(); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + try { + control.reportStatus({ areaId: 'session-manager', state: 'idle', summary: 'Fixture session is available.' }); + const service = new ScheduleService({ + repositoryRoot: context.root, controlPlane: control, + executor: () => ({ + status: 0, + error: Object.assign(new Error('private reasoning=fixture-analysis'), { code: 'ENOBUFS' }), + stdout: 'screenshot-data-'.repeat(200_000), + stderr: 'token=fixture-token password=fixture-password', + }), + }); + const run = service.run({ scheduleId: 'domain-audit', actorId: context.worker }); + assert.equal(run.result, 'failed'); + assert.equal(run.exitStatus, 0); + assert.match(run.stdout, /\[truncated\]/); + assert.doesNotMatch(`${run.stdout}\n${run.stderr}`, /fixture-token|fixture-password|fixture-analysis/); + } finally { control.close(); } +}); diff --git a/test/fleet/generated-check-artifacts.test.mjs b/test/fleet/generated-check-artifacts.test.mjs new file mode 100644 index 0000000..889d65c --- /dev/null +++ b/test/fleet/generated-check-artifacts.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import test from 'node:test'; +import { CheckService } from '../../src/checks/service.mjs'; +import { openControlPlane } from '../../src/control-plane/service.mjs'; +import { analyzeRepository } from '../../src/kernel/analyze.mjs'; +import { proposeDomains } from '../../src/kernel/domains.mjs'; +import { inspectRepository } from '../../src/kernel/git.mjs'; +import { installProject } from '../../src/kernel/install.mjs'; +import { createWorktrees } from '../../src/kernel/worktrees.mjs'; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'torch-generated-artifacts-')); + execFileSync('git', ['init', '-b', 'main', root]); + execFileSync('git', ['-C', root, 'config', 'user.email', 'torch-test@example.invalid']); + execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']); + writeFileSync(join(root, 'package.json'), `${JSON.stringify({ + name: 'generated-artifacts-fixture', scripts: { 'verify:pass': 'node -e "process.exit(0)"' }, + }, null, 2)}\n`); + writeFileSync(join(root, 'app.js'), 'export const app = true;\n'); + mkdirSync(join(root, 'test')); + writeFileSync(join(root, 'test', 'scenario.js'), 'export const scenario = true;\n'); + execFileSync('git', ['-C', root, 'add', '.']); + execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']); + let repository = inspectRepository(root); + const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + proposal.review = { status: 'approved', reviewedAt: '2026-09-30T00:00:00Z', reviewedBy: 'fixture-owner', notes: [] }; + const env = { ...process.env, XDG_DATA_HOME: join(tmpdir(), `${basename(root)}-state`) }; + installProject({ repository, proposal, env, projectId: 'generated-artifacts-fixture' }); + execFileSync('git', ['-C', root, 'add', '.torch']); + execFileSync('git', ['-C', root, 'commit', '-m', 'install torch']); + repository = inspectRepository(root); + createWorktrees({ repository, parentOverride: join(tmpdir(), `${basename(root)}-worktrees`) }); + return { root, env, worker: proposal.domains.find((domain) => domain.id !== 'qa').id }; +} + +function addCheck(context, definition) { + const path = join(context.root, '.torch', 'torch.yaml'); + const config = JSON.parse(readFileSync(path, 'utf8')); + config.checks.push(definition); + writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); +} + +function treeStatus(path) { + return execFileSync('git', ['-C', path, 'status', '--porcelain'], { encoding: 'utf8' }); +} + +test('SCN-generated-check-artifacts: sequential exact checks retain external hashed evidence without changing tracked inputs', () => { + const context = fixture(); + addCheck(context, { + id: 'emit-proof', command: process.execPath, + args: ['-e', `const fs=require('node:fs');const path=require('node:path'); + const output=process.env.TORCH_CHECK_ARTIFACT_DIR;if(!output)process.exit(44); + fs.mkdirSync(path.join(output,'screenshots'),{recursive:true}); + fs.writeFileSync(path.join(output,'screenshots','proof.txt'),'captured boundary evidence');`], + }); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + let id = 0; + const checks = new CheckService({ repositoryRoot: context.root, controlPlane: control, + idFactory: () => `artifact-run-${++id}` }); + const expectedHash = createHash('sha256').update('captured boundary evidence').digest('hex'); + try { + const receipts = [ + checks.run({ checkId: 'emit-proof', areaId: context.worker }), + checks.run({ checkId: 'emit-proof', areaId: context.worker }), + ]; + for (const receipt of receipts) { + assert.equal(receipt.result, 'pass'); + assert.equal(treeStatus(receipt.worktree), '', 'a passing check must leave its tested tree unchanged'); + const retained = JSON.parse(readFileSync(receipt.artifactPath, 'utf8')).generatedArtifacts; + assert.deepEqual(retained.candidate, { commit: receipt.commit }); + assert.deepEqual(retained.check, { id: 'emit-proof', + definitionHash: createHash('sha256').update(JSON.stringify(receipt.definition)).digest('hex') }); + assert.deepEqual(retained.run, { id: receipt.id }); + assert.deepEqual(retained.files, [{ path: 'screenshots/proof.txt', bytes: 26, sha256: expectedHash }]); + assert.equal(readFileSync(join(retained.directory, 'screenshots', 'proof.txt'), 'utf8'), 'captured boundary evidence'); + } + assert.notEqual(receipts[0].generatedArtifacts.directory, receipts[1].generatedArtifacts.directory, + 'each run retains its own recoverable evidence directory'); + assert.equal(checks.exactPasses({ commit: receipts[0].commit, requiredChecks: ['emit-proof'] }), true); + } finally { + control.close(); + } +}); + +test('SCN-generated-check-mutation: exit zero plus a tested-tree mutation is incomplete even when external evidence was retained', () => { + const context = fixture(); + addCheck(context, { + id: 'emit-and-mutate', command: process.execPath, + args: ['-e', `const fs=require('node:fs');const path=require('node:path'); + fs.writeFileSync(path.join(process.env.TORCH_CHECK_ARTIFACT_DIR,'proof.txt'),'retained despite mutation'); + fs.writeFileSync('app.js','source mutation');`], + }); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + const checks = new CheckService({ repositoryRoot: context.root, controlPlane: control, + idFactory: () => 'mutation-run' }); + try { + const receipt = checks.run({ checkId: 'emit-and-mutate', areaId: context.worker }); + assert.equal(receipt.exitStatus, 0); + assert.equal(receipt.result, 'incomplete'); + assert.equal(receipt.invalidReason, 'worktree-changed-during-check'); + const retained = JSON.parse(readFileSync(receipt.artifactPath, 'utf8')).generatedArtifacts; + assert.equal(existsSync(join(retained.directory, 'proof.txt')), true); + assert.notEqual(treeStatus(receipt.worktree), ''); + assert.equal(checks.exactPasses({ commit: receipt.commit, requiredChecks: ['emit-and-mutate'] }), false); + } finally { + control.close(); + } +}); diff --git a/test/fleet/lifecycle.test.mjs b/test/fleet/lifecycle.test.mjs index 14effb5..28e5e07 100644 --- a/test/fleet/lifecycle.test.mjs +++ b/test/fleet/lifecycle.test.mjs @@ -15,7 +15,7 @@ import { defaultManagerCheckInSchedule, organizationGraphFromConfig } from '../. import { createWorktrees } from '../../src/kernel/worktrees.mjs'; import { ResourceService } from '../../src/resources/service.mjs'; import { - createFleetBrief, detachFleet, planFleetDetach, planFleetDown, planFleetUp, startFleet, stopFleet, + createFleetBrief, detachFleet, planAreaUp, planFleetDetach, planFleetDown, planFleetUp, startFleet, stopFleet, } from '../../src/runtime/lifecycle.mjs'; function fleetFixture({ workerRuntime = 'claude', hierarchy = false } = {}) { @@ -331,7 +331,7 @@ test('SCN-mixed-runtime: Codex and Claude share stable identities while planning const started = startFleet({ plan, controlPlane: control, adapters, executor: (launch) => launch.areaId === fixture.worker - ? { status: 0, stdout: '{"type":"thread.started","thread_id":"codex-worker-1"}\n' } + ? { status: 0, stdout: '{"type":"thread.started","thread_id":"codex-worker-1"}\n{"type":"turn.completed"}\n' } : { status: 0, stdout: 'claude-manager-1\n' }, }); assert.deepEqual(started.started.map((item) => item.runtimeSessionId), [ @@ -360,3 +360,124 @@ test('SCN-mixed-runtime: Codex and Claude share stable identities while planning assert.equal(control.identity(fixture.worker).state, 'offline'); control.close(); }); + +test('SCN-codex-terminal-completion: a captured Codex identity is not a completed turn without terminal evidence', () => { + const fixture = fleetFixture({ workerRuntime: 'codex' }); + const control = openControlPlane({ repositoryRoot: fixture.root, env: fixture.env }); + const adapters = new Map([ + ['claude', createClaudeAdapter()], + ['codex', createCodexAdapter({ executable: 'codex' })], + ]); + const plan = planFleetUp({ repositoryRoot: fixture.root, controlPlane: control, adapters, fresh: true }); + assert.throws(() => startFleet({ + plan, controlPlane: control, adapters, + executor: (launch) => launch.areaId === fixture.worker + ? { status: 0, stdout: '{"type":"thread.started","thread_id":"codex-interrupted-1"}\n' } + : { status: 0, stdout: 'claude-manager-1\n' }, + }), (error) => error.code === 'FLEET_START_FAILED' + && error.details.executorOutcome.reason === 'codex-terminal-event-missing'); + assert.equal(control.identity(fixture.worker).state, 'working'); + assert.equal(control.identity(fixture.worker).runtimeSessionId, 'codex-interrupted-1'); + const duplicate = planAreaUp({ + repositoryRoot: fixture.root, controlPlane: control, areaId: fixture.worker, adapters, + }); + assert.equal(duplicate.canProceed, false); + assert.deepEqual(duplicate.blockers, [{ + areaId: fixture.worker, code: 'RUNTIME_IDENTITY_ACTIVE', state: 'working', + runtimeSessionId: 'codex-interrupted-1', + }]); + control.close(); +}); + +test('SCN-codex-startup-diagnostics: owner-visible startup failures prefer bounded structured Codex evidence without disclosure', () => { + const modelFixture = fleetFixture({ workerRuntime: 'codex' }); + const modelControl = openControlPlane({ repositoryRoot: modelFixture.root, env: modelFixture.env }); + const modelAdapters = new Map([ + ['claude', createClaudeAdapter()], + ['codex', createCodexAdapter({ executable: 'codex' })], + ]); + const modelPlan = planFleetUp({ + repositoryRoot: modelFixture.root, controlPlane: modelControl, adapters: modelAdapters, fresh: true, + }); + let modelFailure; + assert.throws( + () => startFleet({ + plan: modelPlan, controlPlane: modelControl, adapters: modelAdapters, + executor: (launch) => launch.areaId === modelFixture.worker + ? { + status: 23, + stdout: '{"type":"error","code":"MODEL_NOT_SUPPORTED","message":"Requested model fixture-model is not supported","prompt":"fixture prompt must not leak"}\n', + stderr: 'Vercel MCP authentication warning: token=fixture-token password=fixture-password', + } + : { status: 0, stdout: 'claude-manager-1\n' }, + }), + (error) => { + modelFailure = error; + return error.code === 'FLEET_START_FAILED' && error.details.status === 23; + }, + ); + assert.deepEqual(modelFailure.details.diagnostic, { + schema: 'torch.dev/runtime-startup-diagnostic/v1alpha1', outcome: 'known', + category: 'model-rejection', code: 'MODEL_NOT_SUPPORTED', + message: 'Requested model fixture-model is not supported', + stderr: 'Vercel MCP authentication warning: token=[REDACTED] password=[REDACTED]', + }); + assert.equal(modelFailure.details.stderr, modelFailure.details.diagnostic.stderr); + assert.doesNotMatch(JSON.stringify(modelFailure.details), /fixture prompt|fixture-token|fixture-password/); + assert.equal(modelControl.identity(modelFixture.worker).state, 'offline'); + modelControl.close(); + + const malformedFixture = fleetFixture({ workerRuntime: 'codex' }); + const malformedControl = openControlPlane({ repositoryRoot: malformedFixture.root, env: malformedFixture.env }); + const malformedAdapters = new Map([ + ['claude', createClaudeAdapter()], + ['codex', createCodexAdapter({ executable: 'codex' })], + ]); + const malformedPlan = planFleetUp({ + repositoryRoot: malformedFixture.root, controlPlane: malformedControl, adapters: malformedAdapters, fresh: true, + }); + let malformedFailure; + assert.throws( + () => startFleet({ + plan: malformedPlan, controlPlane: malformedControl, adapters: malformedAdapters, + executor: (launch) => launch.areaId === malformedFixture.worker + ? { status: 24, stdout: '{malformed Codex event', stderr: 'MCP warning' } + : { status: 0, stdout: 'claude-manager-2\n' }, + }), + (error) => { + malformedFailure = error; + return error.code === 'FLEET_START_FAILED' && error.details.status === 24; + }, + ); + assert.equal(malformedFailure.details.diagnostic.outcome, 'unknown'); + assert.equal(malformedFailure.details.diagnostic.reason, 'stdout-malformed-json'); + assert.equal(malformedFailure.details.diagnostic.stderr, 'MCP warning'); + malformedControl.close(); + + const oversizedFixture = fleetFixture({ workerRuntime: 'codex' }); + const oversizedControl = openControlPlane({ repositoryRoot: oversizedFixture.root, env: oversizedFixture.env }); + const oversizedAdapters = new Map([ + ['claude', createClaudeAdapter()], + ['codex', createCodexAdapter({ executable: 'codex' })], + ]); + const oversizedPlan = planFleetUp({ + repositoryRoot: oversizedFixture.root, controlPlane: oversizedControl, adapters: oversizedAdapters, fresh: true, + }); + let oversizedFailure; + assert.throws( + () => startFleet({ + plan: oversizedPlan, controlPlane: oversizedControl, adapters: oversizedAdapters, + executor: (launch) => launch.areaId === oversizedFixture.worker + ? { status: 25, stdout: 'x'.repeat((16 * 1024) + 1), stderr: 'MCP warning' } + : { status: 0, stdout: 'claude-manager-3\n' }, + }), + (error) => { + oversizedFailure = error; + return error.code === 'FLEET_START_FAILED' && error.details.status === 25; + }, + ); + assert.equal(oversizedFailure.details.diagnostic.outcome, 'unknown'); + assert.equal(oversizedFailure.details.diagnostic.reason, 'stdout-exceeds-bound'); + assert.doesNotMatch(JSON.stringify(oversizedFailure.details), /x{512}/); + oversizedControl.close(); +}); diff --git a/test/fleet/provider-updates.test.mjs b/test/fleet/provider-updates.test.mjs index b79b8ba..7f363c0 100644 --- a/test/fleet/provider-updates.test.mjs +++ b/test/fleet/provider-updates.test.mjs @@ -34,7 +34,7 @@ function fixture() { return { status: 0, stdout: 'installed' }; } return { status: 0, stdout: args[0] === '--version' ? `provider ${version}` - : args.includes('exec') ? '{"type":"thread.started","thread_id":"provider-update-fixture-session"}\n' : 'help' }; + : args.includes('exec') ? '{"type":"thread.started","thread_id":"provider-update-fixture-session"}\n{"type":"turn.completed"}\n' : 'help' }; }; return { env, calls, runner, next: v => { version = v; }, breakInstall: () => { failInstall = true; } }; } diff --git a/test/fleet/routine-coordination.test.mjs b/test/fleet/routine-coordination.test.mjs new file mode 100644 index 0000000..b5f2bfe --- /dev/null +++ b/test/fleet/routine-coordination.test.mjs @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import test from 'node:test'; +import { createClaudeAdapter } from '../../src/adapters/claude.mjs'; +import { createBacklogService } from '../../src/backlog/service.mjs'; +import { openControlPlane } from '../../src/control-plane/service.mjs'; +import { analyzeRepository } from '../../src/kernel/analyze.mjs'; +import { proposeDomains } from '../../src/kernel/domains.mjs'; +import { inspectRepository } from '../../src/kernel/git.mjs'; +import { installProject } from '../../src/kernel/install.mjs'; +import { createWorktrees } from '../../src/kernel/worktrees.mjs'; +import { planAreaUp, startFleet } from '../../src/runtime/lifecycle.mjs'; +import { ScheduleService } from '../../src/schedules/service.mjs'; + +function fixture({ pausedManagerWake = false } = {}) { + const root = mkdtempSync(join(tmpdir(), 'torch-routine-coordination-')); + execFileSync('git', ['init', '-b', 'main', root]); + execFileSync('git', ['-C', root, 'config', 'user.email', 'torch-test@example.invalid']); + execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']); + mkdirSync(join(root, 'test')); + writeFileSync(join(root, 'app.js'), 'export const app = true;\n'); + writeFileSync(join(root, 'test', 'app.test.js'), '// independent verification surface\n'); + writeFileSync(join(root, 'test', 'integration.test.js'), '// independent integration surface\n'); + execFileSync('git', ['-C', root, 'add', '.']); + execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']); + const repository = inspectRepository(root); + const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + if (pausedManagerWake) { + const schedule = proposal.schedules.find((entry) => entry.action?.type === 'manager-check-in'); + assert.ok(schedule, 'fixture must configure the Session Manager check-in schedule'); + schedule.action = { ...schedule.action, wake: { enabled: false } }; + } + proposal.review = { + status: 'approved', reviewedAt: '2026-09-30T00:00:00Z', reviewedBy: 'fixture-owner', notes: [], + }; + const env = { ...process.env, XDG_DATA_HOME: join(tmpdir(), `${basename(root)}-state`) }; + installProject({ repository, proposal, env, projectId: 'routine-coordination-fixture' }); + execFileSync('git', ['-C', root, 'add', '.torch']); + execFileSync('git', ['-C', root, 'commit', '-m', 'install torch']); + createWorktrees({ repository: inspectRepository(root), + parentOverride: join(tmpdir(), `${basename(root)}-worktrees`) }); + const worker = proposal.domains.find((domain) => domain.id !== 'qa')?.id; + const peer = proposal.domains.find((domain) => domain.id === 'qa')?.id; + assert.ok(worker, 'fixture must provide an implementation specialist'); + assert.ok(peer, 'fixture must provide an independent peer'); + return { root, env, worker, peer }; +} + +test('SCN-routine-coordination-instructions: a fresh install gives common, manager, specialist, and resume surfaces the same bounded policy', () => { + const context = fixture(); + const files = [ + join(context.root, '.torch', 'prompts', 'COMMON.md'), + join(context.root, '.torch', 'prompts', 'session-manager.md'), + join(context.root, '.torch', 'prompts', `${context.worker}.md`), + join(context.root, '.torch', 'RESUME-BRIEF.md'), + ].map((path) => readFileSync(path, 'utf8')); + for (const text of files) { + assert.match(text, /acknowledgement does not resolve tasks, approvals, or waits|it never resolves its linked task, approval, handoff, or wait/); + assert.match(text, /responsible peer directly|directly with responsible peers/); + assert.match(text, /paused execution/); + } + assert.match(files[0], /spending or provider changes, remote publication, release or deployment/); + assert.match(files[1], /cannot decide an owner approval or another specialist's approval/); + assert.match(files[2], /Do not start providers, change wake\/timer\/budget policy, dispatch arbitrary work/); +}); + +test('SCN-routine-coordination-boundaries: acknowledgement leaves work pending and only the named approver can decide', () => { + const context = fixture(); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env, + clock: () => new Date('2026-09-30T12:00:00Z') }); + try { + const backlog = createBacklogService({ repositoryRoot: context.root, controlPlane: control }); + const created = backlog.create({ actorId: 'session-manager', title: 'Bounded coordination', + description: 'Keep the wait visible until an authority acts.', acceptanceCriteria: ['Evidence is retained.'], + affectedDomains: [context.worker] }); + const ready = backlog.transition({ taskId: created.id, actorId: 'session-manager', to: 'ready', + expectedRevision: created.revision }); + const assigned = backlog.transition({ taskId: ready.id, actorId: 'session-manager', to: 'assigned', + owner: context.worker, expectedRevision: ready.revision }); + const taskMessage = control.sendMessage({ sender: 'session-manager', recipient: context.worker, + body: 'Work is assigned; preserve the decision wait.', references: { task: assigned.id } }); + control.ackMessage({ recipient: context.worker, messageId: taskMessage.id }); + assert.equal(backlog.get(assigned.id).state, 'assigned', 'reading a task message cannot complete or advance work'); + + const peerApproval = control.requestApproval({ requester: context.worker, approver: context.peer, + task: assigned.id, title: 'Peer path consent', summary: 'Confirm the public boundary.', evidence: 'test/fleet/routine-coordination.test.mjs' }); + const peerMessage = control.readMessages({ recipient: context.peer, unacknowledgedOnly: true }) + .find((message) => message.kind === 'approval-request' && message.references.task === assigned.id); + assert.ok(peerMessage, 'the named approver receives a durable approval request'); + control.ackMessage({ recipient: context.peer, messageId: peerMessage.id }); + assert.equal(control.listApprovals({ actorId: context.peer, status: 'pending' })[0].id, peerApproval.id, + 'acknowledging an approval request cannot decide it'); + assert.throws(() => control.decideApproval({ approvalId: peerApproval.id, decidedBy: 'session-manager', + decision: 'approved', expectedRevision: peerApproval.revision }), + (error) => error.code === 'APPROVER_AUTHORITY_REQUIRED'); + const peerDecision = control.decideApproval({ approvalId: peerApproval.id, decidedBy: context.peer, + decision: 'approved', expectedRevision: peerApproval.revision, note: 'Boundary reviewed.' }); + assert.equal(peerDecision.status, 'approved'); + assert.equal(peerDecision.decidedBy, context.peer); + + const ownerApproval = control.requestApproval({ requester: context.worker, approver: 'owner', task: assigned.id, + title: 'Owner scope decision', summary: 'A peer cannot make this owner decision.' }); + assert.throws(() => control.decideApproval({ approvalId: ownerApproval.id, decidedBy: 'session-manager', + decision: 'approved', expectedRevision: ownerApproval.revision }), + (error) => error.code === 'APPROVER_AUTHORITY_REQUIRED'); + assert.equal(control.listApprovals({ actorId: context.worker, status: 'pending' }).some((item) => + item.id === ownerApproval.id), true, 'a rejected manager attempt leaves the owner decision pending'); + } finally { + control.close(); + } +}); + +test('SCN-routine-coordination-paused-dispatch: an unapproved public start leaves work and owner waits unresolved', () => { + const context = fixture(); + let adapterCalls = 0; + const adapter = createClaudeAdapter({ executable: 'claude', runner: () => { + adapterCalls++; + throw new Error('A disabled provider must not run.'); + } }); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env, + clock: () => new Date('2026-09-30T12:00:00Z') }); + try { + const backlog = createBacklogService({ repositoryRoot: context.root, controlPlane: control }); + const created = backlog.create({ actorId: 'session-manager', title: 'Paused dispatch boundary', + description: 'Keep assigned work visible while provider execution is disabled.', + acceptanceCriteria: ['The start boundary fails closed.'], affectedDomains: [context.worker] }); + const ready = backlog.transition({ taskId: created.id, actorId: 'session-manager', to: 'ready', + expectedRevision: created.revision }); + const assigned = backlog.transition({ taskId: ready.id, actorId: 'session-manager', to: 'assigned', + owner: context.worker, expectedRevision: ready.revision }); + const taskMessage = control.sendMessage({ sender: 'session-manager', recipient: context.worker, + body: 'Wait for explicit provider authorization.', references: { task: assigned.id } }); + control.ackMessage({ recipient: context.worker, messageId: taskMessage.id }); + const peerApproval = control.requestApproval({ requester: context.worker, approver: context.peer, + task: assigned.id, title: 'Peer consent', summary: 'Approve the bounded routine path.' }); + control.decideApproval({ approvalId: peerApproval.id, decidedBy: context.peer, decision: 'approved', + expectedRevision: peerApproval.revision, note: 'Routine path reviewed.' }); + const ownerWait = control.requestApproval({ requester: context.worker, approver: 'owner', task: assigned.id, + title: 'Owner provider decision', summary: 'Provider execution remains owner-reserved.' }); + + const plan = planAreaUp({ repositoryRoot: context.root, controlPlane: control, areaId: context.worker, + adapter, fresh: true }); + assert.equal(plan.canProceed, true, JSON.stringify(plan.blockers)); + assert.throws(() => startFleet({ plan, controlPlane: control }), + (error) => error.code === 'RUNTIME_EXECUTION_NOT_AUTHORIZED'); + assert.equal(adapterCalls, 0, 'the disabled public start cannot invoke a provider adapter'); + assert.equal(backlog.get(assigned.id).state, 'assigned', 'acknowledgement and refusal cannot complete work'); + assert.equal(control.listApprovals({ actorId: context.worker, status: 'pending' }).some((item) => + item.id === ownerWait.id), true, 'the owner wait remains visible after the blocked start'); + assert.equal(control.identity(context.worker).state, 'offline', 'a blocked start cannot report false idle or success'); + } finally { + control.close(); + } +}); + +test('SCN-routine-coordination-paused-schedule-dispatch: configured paused wakes queue attention without a provider invocation', () => { + const context = fixture({ pausedManagerWake: true }); + const at = new Date('2026-09-30T12:00:00Z'); + let providerWakeCalls = 0; + let commandCalls = 0; + const control = openControlPlane({ repositoryRoot: context.root, env: context.env, clock: () => at }); + try { + const backlog = createBacklogService({ repositoryRoot: context.root, controlPlane: control }); + const created = backlog.create({ actorId: 'session-manager', title: 'Paused scheduled dispatch', + description: 'Keep assigned work and the owner wait visible while wakes are paused.', + acceptanceCriteria: ['The paused schedule queues review without a provider invocation.'], + affectedDomains: [context.worker] }); + const ready = backlog.transition({ taskId: created.id, actorId: 'session-manager', to: 'ready', + expectedRevision: created.revision }); + const assigned = backlog.transition({ taskId: ready.id, actorId: 'session-manager', to: 'assigned', + owner: context.worker, expectedRevision: ready.revision }); + control.reportStatus({ areaId: context.worker, state: 'waiting', task: assigned.id, + summary: 'Waiting for the owner provider decision.' }); + const ownerWait = control.requestApproval({ requester: context.worker, approver: 'owner', task: assigned.id, + title: 'Owner provider decision', summary: 'Automatic provider execution is paused.' }); + const schedules = new ScheduleService({ + repositoryRoot: context.root, controlPlane: control, clock: () => at, + executor: () => { commandCalls++; throw new Error('Manager check-in must not invoke a shell command.'); }, + wakeManager: { + plan: () => { providerWakeCalls++; throw new Error('A paused schedule must not plan a provider wake.'); }, + invoke: () => { providerWakeCalls++; throw new Error('A paused schedule must not invoke a provider.'); }, + }, + }); + + const plan = schedules.plan({ scheduleId: 'session-manager-check-in', actorId: 'owner', at }); + assert.equal(plan.wakeEnabled, false, 'the installed schedule must expose an explicit paused wake policy'); + assert.deepEqual(plan.schedule.action.wake, { enabled: false }); + const dispatched = schedules.dispatchSystem({ actorId: 'owner', approved: true, at }); + assert.deepEqual(dispatched.due, ['session-manager-check-in']); + assert.equal(dispatched.runs[0].result, 'succeeded'); + const observed = JSON.parse(dispatched.runs[0].stdout); + assert.equal(observed.checkIn.queued, true); + assert.equal(observed.wake.attempted, false); + assert.equal(observed.wake.reason, 'wake-disabled'); + assert.equal(providerWakeCalls, 0, 'the public scheduled dispatch cannot plan or invoke a paused provider'); + assert.equal(commandCalls, 0, 'the typed check-in cannot fall through to a shell command'); + assert.equal(backlog.get(assigned.id).state, 'assigned', 'the scheduled review cannot complete assigned work'); + assert.equal(control.listApprovals({ actorId: context.worker, status: 'pending' }).some((item) => + item.id === ownerWait.id), true, 'the owner decision remains a durable wait'); + assert.equal(control.identity(context.worker).state, 'waiting', 'the worker cannot report false idle or success'); + assert.equal(control.identity('session-manager').state, 'offline', 'queued review cannot report a manager provider turn'); + assert.equal(control.readMessages({ recipient: 'session-manager', unacknowledgedOnly: true }) + .some((message) => message.kind === 'manager-check-in'), true, 'the manager receives durable attention instead of a wake'); + } finally { + control.close(); + } +}); diff --git a/test/fleet/schedules.test.mjs b/test/fleet/schedules.test.mjs index 536ae04..77e8159 100644 --- a/test/fleet/schedules.test.mjs +++ b/test/fleet/schedules.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; @@ -16,6 +16,25 @@ import { loadProjectConfig, validateProjectConfig } from '../../src/kernel/confi import { CheckService } from '../../src/checks/service.mjs'; import { IntegrationService } from '../../src/integration/service.mjs'; import { createWorktrees } from '../../src/kernel/worktrees.mjs'; + +test('SCN-systemd-native-unit: generated dispatcher is accepted by the real systemd parser', (t) => { + if (process.platform !== 'linux') return t.skip('Native systemd qualification is Linux-specific'); + const context = fixture(); + const alias = join(mkdtempSync(join(tmpdir(), 'torch-unit-path-')), 'project space % "quoted"'); + symlinkSync(context.root, alias, 'dir'); + const launcher = new ScheduleLauncherService({ repositoryRoot: alias, env: context.env, command: ['/usr/bin/true'] }); + const plan = launcher.plan(); + const service = plan.files.find(file => file.name.endsWith('.service')); + const directory = mkdtempSync(join(tmpdir(), 'torch-systemd-verify-')); + const path = join(directory, service.name); + writeFileSync(path, service.content); + assert.match(service.content, /WorkingDirectory=\//); + assert.ok(!service.content.includes('WorkingDirectory="')); + assert.ok(service.content.includes('project\\x20space\\x20%%\\x20\\x22quoted\\x22')); + const checked = spawnSync('systemd-analyze', ['--user', 'verify', path], { encoding: 'utf8' }); + assert.equal(checked.error, undefined, checked.error?.message); + assert.equal(checked.status, 0, checked.stderr); +}); import { createRuntimeAdapterRegistry } from '../../src/adapters/registry.mjs'; import { planAreaUp } from '../../src/runtime/lifecycle.mjs'; import { observeProject } from '../../src/observability/snapshot.mjs'; diff --git a/test/fleet/setup.test.mjs b/test/fleet/setup.test.mjs new file mode 100644 index 0000000..b051f36 --- /dev/null +++ b/test/fleet/setup.test.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync, cpSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { analyzeRepository } from '../../src/kernel/analyze.mjs'; +import { proposeDomains } from '../../src/kernel/domains.mjs'; +import { inspectRepository } from '../../src/kernel/git.mjs'; +import { installProject, readInstallManifest } from '../../src/kernel/install.mjs'; +import { resolveInstalledProjectRoot } from '../../src/kernel/project-root.mjs'; +import { setupProject, restoreProject } from '../../src/kernel/setup.mjs'; +import { openControlPlane } from '../../src/control-plane/service.mjs'; +import { createFleetBrief, detachFleet, planFleetDetach } from '../../src/runtime/lifecycle.mjs'; + +const git = (root, args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'torch-setup-')); + const env = { ...process.env, XDG_DATA_HOME: join(root, 'external-state') }; + git(root, ['init', '-b', 'main']); + git(root, ['config', 'user.name', 'Setup Test']); + git(root, ['config', 'user.email', 'test@example.invalid']); + writeFileSync(join(root, '.gitignore'), 'external-state/\nworktrees/\n'); + writeFileSync(join(root, 'app.js'), 'export const app = true;\n'); + git(root, ['add', '.']); + git(root, ['commit', '-m', 'fixture']); + const repository = inspectRepository(root); + const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + proposal.review = { status: 'approved', reviewedAt: '2026-09-30T00:00:00Z', reviewedBy: 'owner', notes: [] }; + installProject({ repository, proposal, env }); + return { root, env, parentOverride: join(root, 'worktrees') }; +} + +test('SCN-guided-setup: approved setup checkpoints owned files and provisions without agents', () => { + const f = fixture(); + const before = git(f.root, ['rev-parse', 'HEAD']); + assert.equal(setupProject({ ...f, repository: inspectRepository(f.root), dryRun: true }).canProceed, true); + assert.equal(git(f.root, ['rev-parse', 'HEAD']), before); + assert.throws(() => setupProject({ ...f, repository: inspectRepository(f.root) }), { code: 'APPROVAL_REQUIRED' }); + writeFileSync(join(f.root, 'app.js'), 'export const app = false;\n'); + git(f.root, ['add', 'app.js']); + assert.throws(() => setupProject({ ...f, repository: inspectRepository(f.root), authorized: true }), { code: 'SETUP_STAGED_WORK' }); + assert.equal(git(f.root, ['diff', '--cached', '--name-only']), 'app.js'); + git(f.root, ['restore', '--staged', 'app.js']); + writeFileSync(join(f.root, 'app.js'), 'export const app = true;\n'); + const result = setupProject({ ...f, repository: inspectRepository(f.root), authorized: true }); + assert.equal(result.sessionsStarted, false); + assert.ok(result.configurationCommit); + assert.ok(result.registrationCommit); + assert.equal(git(f.root, ['status', '--porcelain']), ''); + assert.ok(git(f.root, ['diff', '--name-only', before, 'HEAD']).split('\n').every(path => path.startsWith('.torch/'))); + assert.ok(readInstallManifest(f.root).external.some(entry => entry.type === 'worktree')); + assert.equal(setupProject({ ...f, repository: inspectRepository(f.root), authorized: true }).mutationPerformed, false); +}); + +test('SCN-worktree-canonical-brief: current instructions win and unregistered or copied worktrees fail closed', () => { + const f = fixture(); + setupProject({ ...f, repository: inspectRepository(f.root), authorized: true }); + const entry = readInstallManifest(f.root).external.find(item => item.type === 'worktree'); + const marker = 'Current canonical instructions, not the old worktree copy.'; + writeFileSync(join(f.root, '.torch/prompts/COMMON.md'), marker); + const canonical = resolveInstalledProjectRoot(entry.path, f.env); + assert.equal(canonical, f.root); + const control = openControlPlane({ repositoryRoot: canonical, env: f.env }); + try { + const brief = createFleetBrief({ repositoryRoot: canonical, controlPlane: control, areaId: entry.area }); + assert.ok(brief.areas[0].prompt.includes(marker)); + } finally { control.close(); } + const stray = join(f.root, 'worktrees', 'stray'); + git(f.root, ['worktree', 'add', '-b', 'stray', stray]); + assert.throws(() => resolveInstalledProjectRoot(stray, f.env), { code: 'UNREGISTERED_PROJECT_WORKTREE' }); + const copied = mkdtempSync(join(tmpdir(), 'torch-copy-')); + cpSync(join(entry.path, '.torch'), join(copied, '.torch'), { recursive: true }); + git(copied, ['init', '-b', entry.branch]); + assert.throws(() => resolveInstalledProjectRoot(copied, f.env), { code: 'UNREGISTERED_PROJECT_WORKTREE' }); + const localManifest = JSON.parse(readFileSync(join(entry.path, '.torch/install-manifest.json'))); + localManifest.installationId = 'stale-installation'; + writeFileSync(join(entry.path, '.torch/install-manifest.json'), JSON.stringify(localManifest)); + assert.throws(() => resolveInstalledProjectRoot(entry.path, f.env), { code: 'UNREGISTERED_PROJECT_WORKTREE' }); +}); + +test('SCN-install-restore: detach and explicit restore preserve configuration and worktree history without starting sessions', () => { + const f = fixture(); + setupProject({ ...f, repository: inspectRepository(f.root), authorized: true }); + const manifest = readInstallManifest(f.root); + const metadataPath = join(manifest.external.find(entry => entry.type === 'local-state').path, 'project.json'); + const config = readFileSync(join(f.root, '.torch/torch.yaml'), 'utf8'); + const heads = manifest.external.filter(entry => entry.type === 'worktree').map(entry => git(entry.path, ['rev-parse', 'HEAD'])); + const control = openControlPlane({ repositoryRoot: f.root, env: f.env }); + try { + detachFleet({ plan: planFleetDetach({ repositoryRoot: f.root, controlPlane: control, adapters: {} }), controlPlane: control, stopRuntime: () => { throw new Error('Offline setup must not call a provider'); } }); + } finally { control.close(); } + const before = readFileSync(metadataPath, 'utf8'); + assert.equal(restoreProject({ repository: inspectRepository(f.root), env: f.env, dryRun: true }).wasDetached, true); + assert.equal(readFileSync(metadataPath, 'utf8'), before); + assert.throws(() => restoreProject({ repository: inspectRepository(f.root), env: f.env }), { code: 'APPROVAL_REQUIRED' }); + assert.equal(restoreProject({ repository: inspectRepository(f.root), env: f.env, authorized: true }).sessionsStarted, false); + assert.equal(JSON.parse(readFileSync(metadataPath)).detachedAt, null); + assert.equal(readFileSync(join(f.root, '.torch/torch.yaml'), 'utf8'), config); + assert.deepEqual(manifest.external.filter(entry => entry.type === 'worktree').map(entry => git(entry.path, ['rev-parse', 'HEAD'])), heads); +}); diff --git a/torch-release.json b/torch-release.json index 00b9ad6..c91b17d 100644 --- a/torch-release.json +++ b/torch-release.json @@ -1,6 +1,6 @@ { "schema": "torch.dev/release/v1alpha1", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.3", "state": { "reads": ["torch.dev/state/v1alpha1"], "writes": "torch.dev/state/v1alpha1",