mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 07:51:52 +00:00
Merge canonical for TASK-pilot-cli-suite-timeout
This commit is contained in:
commit
0c8db11bcc
41 files changed
+4544
-24
No files matched your search
@@ -2,6 +2,70 @@
|
||||
|
||||
Repository state outranks conversation memory. Query ownership before crossing a domain boundary.
|
||||
|
||||
## Owner-requested restart and continuation repair (2026-10-03 UTC)
|
||||
|
||||
The owner said "yeah fix the issue" after the arbiter verified that the fleet
|
||||
had stopped making progress. The arbiter implemented a schema-compatible,
|
||||
owner-only manual legacy recovery and a bounded deterministic continuation pilot.
|
||||
These are source changes pending independent QA and installed-release acceptance,
|
||||
not completed umbrella tasks. Read
|
||||
docs/spec_changes/2026-10-03-legacy-runtime-recovery.md. The arbiter may apply exact
|
||||
tested stopped-invocation recovery and resume the existing seven identities with
|
||||
the source CLI. Preserve old unknown/failure receipts and existing unique commits.
|
||||
|
||||
The source controller may launch only the explicitly approved existing roster,
|
||||
under its daily turn cap, event deduplication and per-identity physical turn guard.
|
||||
It does not grant agents permission to start providers or bypass budgets, resource
|
||||
leases, approval boundaries or task ownership. Existing legacy manager-wake
|
||||
configuration stays disabled; this source pilot is a distinct, bounded mechanism.
|
||||
Manager should process current inbox and route real next work rather than leaving
|
||||
it at a proposal-only loop. QA may independently review the exact new source and
|
||||
scenario packet now; no fabricated QA acceptance is implied by owner implementation.
|
||||
|
||||
## Owner priority update: reliability before visual polish (2026-10-03 UTC)
|
||||
|
||||
The owner requested that TORCH's operating defects be fixed first so the fleet
|
||||
benefits from improved operation. This supersedes the UI-first ordering below,
|
||||
not the existing resume authorization or safety boundaries. The authoritative
|
||||
backlog priorities were changed through the revision-checked owner API.
|
||||
|
||||
Sequence the smallest safe current-schema stopped-unknown recovery and CLI
|
||||
qualification first; then truthful lifecycle/status and their source-safety
|
||||
prerequisites; then qualified bounded continuation/event-driven wakes under
|
||||
pause, budget and authority policy; then actionable operational attention.
|
||||
Compact cards/detail modals and coordinator Chat remain high-priority work after
|
||||
these reliability gates. Do not require the entire future company architecture
|
||||
to deliver a safe minimal recovery. Reprioritization does not resolve blockers,
|
||||
grant test consent, enable automatic wakes or launch agents. Preserve task owners,
|
||||
dependencies and evidence; explicitly block/resequence existing assignments when
|
||||
needed rather than replacing them silently or creating concurrent assignments.
|
||||
|
||||
## Owner-approved fleet resume and priority polish cycle (2026-10-02)
|
||||
|
||||
The owner explicitly said: "We got a reset. lets get torch as pollished as we can.
|
||||
Spin them all back up." This lifts the earlier manual wind-down for the existing
|
||||
seven identities. The arbiter controls physical launches and timer restoration;
|
||||
automatic AI wakes remain disabled until their runtime safety gates qualify.
|
||||
Do not interpret old pause messages as a new pause after this authorization.
|
||||
|
||||
Session Manager may triage and assign the existing development roadmap within
|
||||
approved ownership, one active implementation assignment per specialist, direct
|
||||
peer coordination and independent QA. The earlier first-slice/audit-only limits
|
||||
below are superseded for this development cycle. Preserve existing unfinished
|
||||
assignments and resolve or explicitly block them rather than silently replacing
|
||||
them to take new work. Prioritize compact cards/detail modals and coordinator-first
|
||||
agent Chat, then broader polish; runtime recovery required for safe operation comes
|
||||
first. Read docs/CONSOLE_CHAT_AND_COMPACT_CARDS_SPEC.md and the newer owner inbox.
|
||||
Company/KB proposals remain roadmap requirements, not implemented capabilities.
|
||||
|
||||
Reconcile the three prior working/unknown records through supported audited
|
||||
recovery, with actual stopped-executor evidence. No peer impersonation, direct
|
||||
SQLite mutation, silent guard removal or false PASS promotion. Current model/
|
||||
provider profiles stay unchanged. Existing checked integration and non-force
|
||||
development push authority remains; no default-main/legacy rewrite, destructive
|
||||
cleanup, extra identities, package publication or unreviewed deployment is granted.
|
||||
Record honest progress and blockers; end completed turns without polling loops.
|
||||
|
||||
## Fresh owner authorization: guarded integration and continued development (2026-10-01)
|
||||
|
||||
The owner explicitly answered the arbiter's two held decisions: "yes do whatever
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
# Adaptive software company: integration plan
|
||||
|
||||
Status: owner-approved roadmap direction, 2026-10-01; implementation pending.
|
||||
Applies to the Portable Agent Fleet. This document records the supplied adaptive
|
||||
software company proposal and the agreed refinements. It does not certify the
|
||||
current alpha as unattended or production-ready. The fleet remains manually
|
||||
paused until the owner explicitly requests resume.
|
||||
|
||||
## Product boundary
|
||||
|
||||
TORCH supplies organization, ownership, intent, authority, durable work,
|
||||
coordination, evidence, verification, integration and recovery. Coding harnesses
|
||||
supply implementation intelligence, tools and temporary delegation. Start with
|
||||
the smallest useful organization; do not require a fixed Engineering/Product/QA
|
||||
taxonomy or a new permanent AI supervisor. Projects may use different roles.
|
||||
|
||||
Preserve worktree isolation, one active implementation assignment per specialist,
|
||||
revision-checked mutations, direct peer communication, resource leases, exact
|
||||
candidate verification, serialized integration, independent review where required,
|
||||
audits and separate release/deployment authority. KB/handbook/working-memory
|
||||
requirements remain governed by [Project knowledge](PROJECT_KNOWLEDGE_SPEC.md).
|
||||
|
||||
## Existing work: extend, do not replace
|
||||
|
||||
This is a staged extension of current systems, not a rewrite. Reuse the current
|
||||
identity, organization, backlog, messaging, runtime, checks, resource, integration,
|
||||
delivery and schedule services. Owner-approved grants are already proposed as
|
||||
`TASK-874ed1e4-9414-4a8f-8575-a6c5aceefc56`; do not build a competing grant system.
|
||||
Link first-work intake, event-driven wakes, usage governor, hierarchy pilots,
|
||||
starting-presence and provider update-path tasks instead of duplicating them.
|
||||
|
||||
The earlier executor-outcome task is completed, but later pilot launches exposed
|
||||
stdout-bound/terminal-outcome disagreement. A completed historical task is not
|
||||
proof that the later installed runtime is ready for unattended operation.
|
||||
Preserve those receipts and qualify the new regression explicitly.
|
||||
|
||||
## Phase A: trustworthy runtime state before unattended coordination
|
||||
|
||||
Lead domains: Provider Runtime, with Project Kernel and independent QA.
|
||||
|
||||
1. Reproduce the installed output-bound failure using bounded deterministic
|
||||
fixtures. Keep parser/transport bounds; do not treat a wrapper's observation
|
||||
as a substitute for an authenticated runtime outcome.
|
||||
2. Define startup reservation, process/session ownership, terminal outcome and
|
||||
recovery contracts. Distinguish success, failure, unknown and physically stopped.
|
||||
3. Publish guarded starting presence before launching. Coordinate manual,
|
||||
scheduled and controller paths through the same exclusivity mechanism.
|
||||
4. Add a supported, owner-attributed recovery operation with executor-stopped
|
||||
evidence, revision/lease checks and audit. Never impersonate a specialist,
|
||||
edit SQLite manually, infer death from a name or drop an unknown reservation.
|
||||
5. Qualify manual pause across reboot, quota reset, schedule due, message arrival,
|
||||
upgrade and event replay. Only explicit owner resume can clear this pause.
|
||||
|
||||
Exit gate: oversized output, timeout, signal, malformed terminal data and a crash
|
||||
between spawn and receipt cannot produce false success or a duplicate launch;
|
||||
unknown outcomes remain recoverable and block unsafe actions. Actual installed
|
||||
runtime qualification and independent QA are required, not just fixture tests.
|
||||
|
||||
## Phase B: scoped capability authority migration
|
||||
|
||||
Lead: Project Kernel; extend the existing owner-grants proposal.
|
||||
|
||||
Identity identifies the participant; role describes responsibility; an approved
|
||||
grant confers authority. Reporting edges and implementation ownership do not
|
||||
confer management capabilities. Never interpret delegated user authority as
|
||||
permission to impersonate the owner or act outside its explicit scope.
|
||||
|
||||
Before coding, inventory identity-special business rules and their CLI, MCP,
|
||||
Console, scheduled and internal entry points. Define a single authorization
|
||||
service and a capability mapping for each action. Grant records must identify:
|
||||
|
||||
- grant ID, issuer/delegation provenance, recipient and required capability;
|
||||
- project/domain/task/path scope and operation constraints;
|
||||
- approval/reference evidence and policy/grant revision;
|
||||
- validity/revocation and whether further delegation is allowed;
|
||||
- owner-reserved exclusions and independent-review conflict rules.
|
||||
|
||||
Initially migrate the current Session Manager's permitted operations without
|
||||
widening permissions. Support preview and explicit approval of broader grants.
|
||||
No default wildcard capabilities, hierarchy inheritance or self-granting.
|
||||
Evaluate the current grant immediately before effects; audit the actual actor,
|
||||
grant, scope, relevant revisions and allow/deny reason.
|
||||
|
||||
Exit gate: old permitted actions still work under equivalent grants; ungranted,
|
||||
out-of-scope, revoked and stale actions fail across every entry point. A new lead
|
||||
can be assigned a bounded capability without source changes. Independent review
|
||||
cannot approve its own work merely because it also holds a leadership role.
|
||||
|
||||
## Phase C: durable event contract
|
||||
|
||||
Lead: Work & Integration, with Kernel/Runtime; extend existing event-driven wakes.
|
||||
|
||||
Version events for work-ready/blocked, assignment completion, verification,
|
||||
integration readiness, approval requests/decisions, pending messages, resource
|
||||
release, runtime observations, schedule due and evidence-backed organization
|
||||
pressure. Existing audit records are evidence, not automatically executable orders.
|
||||
|
||||
Each event needs an ID, project, type/schema version, source entity and revision,
|
||||
actor/cause, timestamp, correlation/causation IDs and deduplication key. Keep
|
||||
payloads bounded and avoid credentials, private document bodies or full transcripts.
|
||||
|
||||
Define emission and recovery at each state-change boundary. Use transactional
|
||||
outbox semantics where one store supports them; when Git/files and SQLite cross
|
||||
a boundary, specify a durable journal/reconciliation protocol rather than claiming
|
||||
one atomic transaction. Preserve one authority for task state. Define cursor,
|
||||
retention, replay and schema-upgrade behavior before enabling consumers.
|
||||
|
||||
Exit gate: crash before/after state mutation or event delivery, duplicate delivery,
|
||||
out-of-order delivery and replay cannot lose a significant transition or authorize
|
||||
a stale action. Event delivery may be at-least-once; effects must be idempotent.
|
||||
|
||||
## Phase D: small desired-state reconciler
|
||||
|
||||
Lead: Work & Integration / Provider Runtime. No strategy or implementation logic.
|
||||
|
||||
For each supported action, observe authoritative current state, compare with
|
||||
approved policy and desired work, evaluate eligibility, reserve one bounded action,
|
||||
recheck revisions/authority/pause/budget, execute and record the outcome.
|
||||
Eligibility is allow, deny or unknown; unknown is not permission to retry.
|
||||
|
||||
Begin with durable notices and read-only explanations. Then pilot wake of one
|
||||
eligible identity for assigned work, followed by narrowly configured ready-work
|
||||
self-claim and integration-authority notifications. Do not automatically approve,
|
||||
land, release, deploy, reassign ownership or create identities just because an
|
||||
event arrived. Preserve existing checks and authorization gates for each operation.
|
||||
|
||||
Require persisted pause/backoff, launch reservations, concurrency limits,
|
||||
bounded retry rules, actionable failures and periodic safety reconciliation.
|
||||
Queue/backend/usage/model/auth unavailability must not create busy model loops.
|
||||
Check budgets before launching, including uncertainty in subscription usage.
|
||||
Use minimum useful wake context; a timer is a safety net, not another AI supervisor.
|
||||
|
||||
Exit gate: duplicate events cannot duplicate execution; paused or uncertain work
|
||||
is never restarted; missed events are found by reconciliation; stale observations
|
||||
cannot override a newly revoked grant. Start with an owner-approved bounded pilot,
|
||||
manual intervention metric and immediate pause/rollback path.
|
||||
|
||||
## Phase E: native execution capabilities and repository evidence
|
||||
|
||||
Runtime lead: Provider Runtime. Repository evidence lead: Project Kernel.
|
||||
|
||||
Keep execution capabilities separate from lifecycle functions. Describe native
|
||||
subagents/delegation, browser/computer use, artifacts, worktrees, background tasks,
|
||||
checkpoints and review as advertised, locally available, qualified, unavailable
|
||||
or unknown. Bind qualification to executable/version, host, account/config and
|
||||
evidence; declarations alone do not prove availability. No model calls merely
|
||||
to populate the inventory without approval.
|
||||
|
||||
Temporary harness workers are accountable to the persistent TORCH specialist,
|
||||
not new roster members. They must not acquire broader TORCH credentials, authority,
|
||||
ownership or uncontrolled resource access. Keep delegation provenance and aggregate
|
||||
available cost evidence. Coordinate native worktrees with TORCH-owned workspaces.
|
||||
Native review is useful but not automatically independent QA. Avoid orchestration
|
||||
squared and promote workers only through evidence-backed organizational proposals.
|
||||
|
||||
Extend deterministic reconnaissance with bounded workspace/build/module graphs,
|
||||
CODEOWNERS, co-change observations, entry points, schemas, deployment/process
|
||||
boundaries and recurring cross-domain tasks. Give the Architect provenance,
|
||||
coverage and uncertainty, not invented semantic conclusions. Do not execute
|
||||
untrusted build tools or fetch history merely to analyze a repository silently.
|
||||
|
||||
Exit gate: absent capabilities have honest fallbacks; helper delegation cannot
|
||||
expand authority; organization proposals cite real evidence and remain advisory.
|
||||
|
||||
## Phase F: owner-oriented onboarding and evidence UX
|
||||
|
||||
Leads: Owner Console, Release/Self-host and Kernel. Extend first-work intake.
|
||||
|
||||
Offer one guided path through prerequisites/runtime qualification, analysis,
|
||||
small-team proposal and rationale, ownership/check gaps, explicit review,
|
||||
installation/worktree preview, first-work intake and optional startup. Preserve
|
||||
advanced CLI and diagnostics. Do not redefine the existing read-only `init` into
|
||||
a mutating command without a documented compatibility decision and tests.
|
||||
|
||||
"Company ready" must distinguish configured, launchable and actually operational.
|
||||
Missing models/auth, unknown outcomes, unresolved ownership and unqualified checks
|
||||
must remain visible. Separate consent for downloads, spending, persistent services,
|
||||
agent startup and publication; onboarding cannot override a manual pause.
|
||||
|
||||
Show conclusions, reason, evidence and exact artifacts progressively, with stable
|
||||
links to commits, check conditions, receipts, decisions and discussions. Derive
|
||||
implemented/verified/independently reviewed/integrated/released/live states from
|
||||
their actual authorities. Surface unknown coverage and rejected/relaxed criteria;
|
||||
green exit alone does not support a claimed business outcome or performance number.
|
||||
|
||||
Exit gate: a fresh user can reach useful reviewed work without knowing SQLite,
|
||||
runtime IDs or schedule unit internals; desktop/mobile/keyboard browser inspection
|
||||
passes. Every important asserted status can be traced to exact evidence.
|
||||
|
||||
## Phase G: adaptive organization and storage investigation
|
||||
|
||||
Extend existing evolution/hierarchy pilots to evidence-backed contraction,
|
||||
promotion and consolidation. Roles must justify recurring outcomes rather than
|
||||
headcount. Record baseline/window, hypothesis, costs, success/stop criteria and
|
||||
owner-reviewed pilot. Transfer authority, ownership, docs and unfinished work
|
||||
safely; never retire an active/dirty/unknown worker. Keep one implementation owner.
|
||||
|
||||
Measure bookkeeping friction before changing storage: dirty manager time,
|
||||
administrative commits, collisions, checkpoint effort and integration delay
|
||||
attributable to state. Compare current tracked records, dedicated state branch
|
||||
and Git-intent/SQLite-operations alternatives against offline recovery, export,
|
||||
backup and migration requirements. Do not dual-write authoritative task state or
|
||||
move it until a separate reviewed decision and recovery tests justify the change.
|
||||
|
||||
## Phase H: multi-project and release qualification
|
||||
|
||||
Use separate controlled fixtures/pilots for a small web app, monorepo, compiled
|
||||
systems project, graphics/game project and optional docs/data-heavy project.
|
||||
Record init-to-useful-team, request-to-assignment, verified integration latency,
|
||||
owner interventions, coordination volume, launch failures, false greens and
|
||||
measured/estimated usage separately. Evaluate organization changes for actual benefit.
|
||||
Do not run expensive/live-provider experiments without owner-approved budgets.
|
||||
|
||||
Release gates include migrations/backup compatibility, crash-safe lock recovery,
|
||||
model/auth preflight, repository relocation, provider update compatibility,
|
||||
distribution provenance/signing decision, atomic upgrade/rollback, event replay
|
||||
and platform-specific schedule qualification. Remote execution is optional and
|
||||
needs a separate trust/deployment boundary, not an implicit requirement.
|
||||
|
||||
## Integration and rollback discipline
|
||||
|
||||
- Assign bounded slices through the existing backlog; agree shared contracts first.
|
||||
- Add scenario-based fault and authority tests with Test Integrity Notes. Do not
|
||||
weaken accepted scenarios or substitute simulated success for installed evidence.
|
||||
- Land exact checked commits through the existing integration mechanism, not
|
||||
concurrent direct pushes or a second queue. Keep runtime/parser/security work
|
||||
independently qualified from marketing and UI changes.
|
||||
- Ship additive, disabled-by-default policy/config first. Migrations need explicit
|
||||
version/compatibility rules, backup/restore tests and an upgrade preview.
|
||||
- Do not roll back a database by blindly activating old code. Define supported
|
||||
data downgrade or fail closed while preserving the new store and receipts.
|
||||
- Enable notifications before wakes; qualify one-domain pilot before broad fleet
|
||||
activation. Fresh source is not automatically an installed runtime upgrade.
|
||||
- Give the owner a report of configured/enabled/qualified state and remaining gates.
|
||||
|
||||
## Backlog index
|
||||
|
||||
The authoritative proposals use `TASK-company-` IDs: runtime-recovery,
|
||||
authority-migration, event-contract, reconciler, native-capabilities,
|
||||
repository-evidence, onboarding, evidence-ux, organization-experiments,
|
||||
storage-study and multi-project-qualification. Existing tasks remain authoritative
|
||||
for their previously scoped work. New proposals are unassigned until Manager
|
||||
triage after explicit owner resume; this document is not another work queue.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Priority Console upgrade: compact cards and agent Chat
|
||||
|
||||
Status: owner-approved development requirement; completion is determined by the
|
||||
authoritative backlog and exact installed/browser evidence, not this document.
|
||||
On 2026-10-03 the owner promoted TASK-owner-agent-chat,
|
||||
TASK-console-agent-chat-ui and TASK-console-chat-cards-qualification to urgent.
|
||||
The fleet is explicitly resumed. Existing runtime safety gates and any subsequent
|
||||
owner pause still apply.
|
||||
|
||||
## Immediate owner-facing milestone
|
||||
|
||||
Prioritize a minimal usable Chat: approved coordinator selected by default,
|
||||
activated-agent selector, durable per-agent history and message composer with
|
||||
explicit destination and truthful queued/handled status. Session Manager should
|
||||
assign eligible Console/Runtime/QA contributions without stealing active work.
|
||||
Keep recovery prerequisites progressing alongside this owner-facing milestone.
|
||||
If history/composer can safely ship before automatic wake qualification, propose
|
||||
a named, scoped slice through normal triage; do not silently remove dependencies.
|
||||
Paused or unqualified messaging may persist but must not trigger provider starts.
|
||||
|
||||
## Compact cards and detail modal
|
||||
|
||||
Make card-based views a scanning layer rather than full-document readers. Apply
|
||||
a shared collapsed-height/max-height policy across data types, with consistent
|
||||
heights within each grid/view and responsive size variants. Do not squeeze tables,
|
||||
charts or conversations into an arbitrary universal card height.
|
||||
|
||||
Show title, state, accountable agent, a two/three-line summary and important
|
||||
blockers or decisions. Clamp overflowing ordinary content with an explicit
|
||||
View details action. Critical warnings must remain identifiable and accessible;
|
||||
provide a concise warning summary and full detail rather than clipping it silently.
|
||||
Retain sensible quick actions such as approve/reject on collapsed cards, using
|
||||
existing evidence/revision/preview confirmation and authority checks.
|
||||
|
||||
Clicking the card's detail affordance opens an accessible modal with complete
|
||||
content, evidence, linked conversations/tasks and contextual actions. Nested
|
||||
action buttons must not accidentally open the modal or trigger another mutation.
|
||||
Modal content may scroll. Preserve underlying view/filter/scroll and unsent input;
|
||||
support keyboard access, focus containment/restoration, Escape, labelled headings,
|
||||
mobile full-screen details and explicit stale/deleted-record states. Refresh must
|
||||
not replace unsaved edits or apply an action against an obsolete revision.
|
||||
|
||||
Start with Work and Needs Your Attention, then cover remaining card-based views
|
||||
with the same pattern. Do not replace specialist views or create duplicate data.
|
||||
|
||||
## Conversational Chat view
|
||||
|
||||
Add Chat immediately below Overview in the sidebar. Default to the highest-level
|
||||
approved project coordinator, currently Session Manager, not a permanently
|
||||
hard-coded identity. Show an empty/explanatory state if none is configured; never
|
||||
create or activate an agent to satisfy the screen.
|
||||
|
||||
Provide a familiar chronological message stream, composer and selector for every
|
||||
activated fleet identity. Persistently show Talking to…, identity, role, runtime
|
||||
availability and a distinct visual accent. Colour is supplementary: labels,
|
||||
avatars/initials and accessible contrast must also distinguish participants.
|
||||
|
||||
Combine durable owner exchanges and publishable inter-agent exchanges involving
|
||||
the selected agent, preserving sender, recipient, thread/context and causal links.
|
||||
Clearly distinguish direct owner conversation from collapsible routine fleet
|
||||
traffic; replying must explicitly target the selected agent or named thread and
|
||||
never silently broadcast. Do not claim every harness transcript is available.
|
||||
Reuse existing owner-agent-chat/messages and formal Conversations audit view,
|
||||
not a parallel conversation database. Include links to tasks, approvals, images
|
||||
and supporting evidence. No private chain-of-thought or indiscriminate terminal,
|
||||
secret or private-file exposure.
|
||||
|
||||
Stream publishable replies/progress only when the adapter supports it; otherwise
|
||||
show honest durable-message updates. Expose queued/delivered/being-handled/failed
|
||||
outcomes accurately: acknowledgement alone does not mean work is done. Preserve
|
||||
drafts per recipient, history on navigation/reload, reconnect deduplication and
|
||||
an explicit send destination. New arrivals must not force-scroll a user reading
|
||||
history. Keep the composer usable on mobile and by keyboard.
|
||||
|
||||
Messages can be saved while paused without waking an agent. Future wake-on-message
|
||||
requires separately qualified reservations, budget, authority and owner-approved
|
||||
policy. Casual chat does not bypass an explicit approval or grant new authority.
|
||||
Use clearly labelled demo fixtures with no live APIs, not private fleet history.
|
||||
|
||||
## Acceptance and delivery
|
||||
|
||||
Use realistic long content, many senders, agent switches, interrupted delivery,
|
||||
stale records and pending owner decisions. Inspect desktop/mobile/keyboard flows
|
||||
in an actual browser and retain screenshots. Prove bounded collapsed layout,
|
||||
complete details, action correctness, focus restoration, preserved filters/drafts,
|
||||
safe refresh, accurate chat attribution and no launch on paused send.
|
||||
|
||||
Existing backlog proposals remain authoritative. New high-priority slices cover
|
||||
compact cards/details, Chat presentation and independent browser qualification.
|
||||
Runtime wake safety remains a prerequisite for execution, not a reason to delay
|
||||
safe read-only/chat-intake UI development.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Operational flow improvements and Ops responsibility pilot
|
||||
|
||||
Owner approved 2026-10-03. Extends SELF_HEALING_QUALIFICATION_PLAN.md and existing
|
||||
services, backlog and authority. No additional standing agent is created.
|
||||
|
||||
## Operational incidents and wait resolution
|
||||
|
||||
An incident connects the existing task, message, approval, check, resource and
|
||||
runtime evidence. It does not become a second task ledger. Record stable cause
|
||||
identity, accountable resolver, affected work, first/last observation, next action,
|
||||
configurable response deadline, escalation authority and resolution evidence.
|
||||
Keep received, acknowledged, acted-on and resolved distinct. A reply, timeout,
|
||||
state churn or healthy doctor result cannot resolve the underlying condition.
|
||||
|
||||
Acceptance scenarios:
|
||||
|
||||
- A waits on B, B acknowledges but does not decide: A remains waiting; a due
|
||||
deadline produces one actionable escalation to the correct manager.
|
||||
- A current decision resolves the wait: a refreshed observation removes the
|
||||
active escalation, preserving original history and exact decision evidence.
|
||||
- Duplicate, reordered or missed notifications: one incident per exact cause,
|
||||
periodic reconciliation catches the missing notification; unrelated candidates,
|
||||
scopes and approvers remain separate.
|
||||
- Manager is offline or authority revoked: deterministic monitoring persists
|
||||
the wait and routes only to a currently authorized resolver; it cannot assume
|
||||
approval, impersonate a manager or launch a duplicate executor.
|
||||
- Quota/auth failure or an uncertain live executor: persisted hold/backoff and
|
||||
guarded recovery remain; repeated incident notices do not create model loops.
|
||||
|
||||
Reuse existing event/reconciler, approval-dedup and attention tasks. Contract/API
|
||||
changes require owned review, revision checks and independent QA; no prompt-only
|
||||
claim of successful incident handling.
|
||||
|
||||
## Source adoption and test/QA handoffs
|
||||
|
||||
Provide a supported, attributable path from a legitimate contributor/release
|
||||
candidate to registered ownership and native exact-candidate qualification.
|
||||
Bind source and destination identities, immutable SHA, ownership/path scope,
|
||||
required contributor consent and actual engine/adapter provenance. Preview
|
||||
current branch/worktree/assignment/guard/lease state before any mutation.
|
||||
|
||||
- A clean exact owner-approved release candidate can be transferred or composed
|
||||
through the supported route and produce fresh native check receipts, independent
|
||||
review and serialized integration without a manual branch reset.
|
||||
- Dirty destination, active task/check/lease, stale source or changed approval
|
||||
refuses/defer with an exact remedy and responsible owner; preserve every byte
|
||||
and unique commit. Installation activation never counts as canonical landing.
|
||||
- Interrupted/repeated adoption is idempotent; preserve original lineage and
|
||||
failure evidence. Never fabricate receipts or silently change scope.
|
||||
- Test handoff has a named test implementer, exact permitted paths/scenarios,
|
||||
candidate/revision binding and reviewer. Approved additive tests can proceed
|
||||
without repeatedly seeking the same consent; new scope needs a new decision.
|
||||
- QA rejection returns an actionable issue to a named owner. QA acceptance is
|
||||
exact and independent, never a blanket authority or weakened test criterion.
|
||||
|
||||
Extend the existing candidate-source qualification and authority services, not a
|
||||
parallel landing queue. Agree owned interfaces and contribution boundaries once,
|
||||
then validate them mechanically. Preserve one active assignment per specialist.
|
||||
|
||||
## Additive acceptance for existing Chat and evidence tasks
|
||||
|
||||
Applies to TASK-owner-agent-chat, TASK-console-agent-chat-ui,
|
||||
TASK-company-evidence-ux and TASK-console-attention-actions. Retain all original
|
||||
criteria. The installer currently has no owner criterion-amendment API; attach
|
||||
this owner-approved addendum by durable references, with manager-owned tracked
|
||||
reconciliation rather than unsupported live task-file rewriting.
|
||||
|
||||
- Message status distinguishes queued, delivered/observed, acknowledged,
|
||||
action started, decision/result and failed; unsupported stages remain unknown.
|
||||
- Configurable response expectations show overdue requests and next resolver,
|
||||
using deterministic reconciliation without duplicate wakes or owner nagging.
|
||||
- Explicit permission-denied/provider-unavailable/offline states explain delay;
|
||||
measured response latency is not an invented completion-time guarantee.
|
||||
- Separate process activity from implementing, checking, coordinating and waiting.
|
||||
Progress includes timestamp/source and links to exact changes/checks/decisions;
|
||||
unavailable evidence is unknown, not idle, success or a fabricated percentage.
|
||||
- A continuously active agent with an unresolved wait is visibly waiting or
|
||||
coordinating; acknowledgements and unchanged status reports never imply useful
|
||||
delivery. Display owner interventions and verified integration separately.
|
||||
- Desktop/mobile/keyboard and stale/reconnected observation scenarios preserve
|
||||
current modal accessibility, recipient identity, drafts and approval boundaries.
|
||||
|
||||
## Ops responsibility pilot
|
||||
|
||||
Provider Runtime is the initial Ops engineering contact, with Work and Integration
|
||||
and Release collaborating within their existing implementation ownership.
|
||||
Session Manager retains priority/assignment authority. This request creates no
|
||||
new role capability, worktree, model choice or overlapping implementation owner.
|
||||
Manager must arrange eligible task slices without overloading the existing active
|
||||
assignment. Native temporary helpers stay under their responsible identity and
|
||||
are used only when supported by the approved runtime policy.
|
||||
|
||||
Deterministic monitoring handles routine detection and qualified recovery. Ops
|
||||
investigates novel incidents, proposes repairs/runbooks and measures recurring
|
||||
failure causes. Ops cannot self-approve checks, overwrite dirty work, change
|
||||
product priorities, spend through a paid fallback, or broaden recovery authority.
|
||||
|
||||
Review after ten resolved incidents or seven active development days, whichever
|
||||
comes first. Record investigation vs waiting time, repeat incidents, escalations,
|
||||
owner interventions, regression/recovery failures and displaced implementation
|
||||
work. Recommend a dedicated standing Ops specialist only if sustained incident
|
||||
load crowds out implementation and a distinct ownership/outcome can be shown.
|
||||
Any new identity follows the existing approved organization-change pathway.
|
||||
|
||||
Success means fewer repeated unresolved handoffs and owner interventions without
|
||||
weaker checks, higher failure rates or unbounded retries. Report the baseline and
|
||||
observed result; do not claim the organizational change worked from agent count.
|
||||
@@ -2743,6 +2743,18 @@ and refuses to replace a launcher path not owned by that installation.
|
||||
|
||||
## 27. User interfaces
|
||||
|
||||
The owner-approved [Adaptive software company integration plan](ADAPTIVE_COMPANY_INTEGRATION_PLAN.md)
|
||||
extends this architecture with scoped capabilities, durable events, bounded
|
||||
reconciliation, native-runtime awareness, evidence UX and project-fit onboarding.
|
||||
It preserves current invariants and requires staged migration and qualification;
|
||||
it is not a claim that the adaptive-company upgrade is implemented.
|
||||
|
||||
Project reference documentation, domain handbooks and provisional working memory
|
||||
MUST follow [Project knowledge and domain institutional memory](PROJECT_KNOWLEDGE_SPEC.md).
|
||||
This owner-approved extension requires a Git-backed shared KB, durable domain
|
||||
ownership, compact agent entry points and a dedicated Console Knowledge view.
|
||||
It is a development requirement, not a claim that the feature is implemented.
|
||||
|
||||
### 27.1 CLI
|
||||
|
||||
The target command surface includes:
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Project knowledge and domain institutional memory
|
||||
|
||||
Status: owner-approved development requirement, 2026-10-01. Not implemented.
|
||||
The fleet remains manually paused; recording this requirement does not authorize
|
||||
dispatch, new sessions, model calls, publication or automatic restart.
|
||||
|
||||
## Purpose
|
||||
|
||||
Give users and agents a shared, evolving project reference, while preserving
|
||||
focused domain knowledge across session restarts, provider changes and agent
|
||||
replacement. Documentation should explain how the project actually works, not
|
||||
be a transcript archive, another backlog or a source of implicit authority.
|
||||
|
||||
## Three connected layers
|
||||
|
||||
1. **Project KB:** authoritative, Git-backed Markdown documenting architecture,
|
||||
setup, interfaces, workflows, pipelines, troubleshooting and decisions.
|
||||
2. **Domain handbook:** a compact entry point owned by the domain, linking to
|
||||
relevant KB pages and recording domain conventions, procedures and pitfalls.
|
||||
Prefer links over duplicated shared contracts. Knowledge belongs to the
|
||||
durable domain identity, not a provider conversation or disposable session.
|
||||
3. **Working memory:** current investigations, hypotheses, handoffs and unfinished
|
||||
work. It is explicitly provisional; promotion to reference documentation
|
||||
requires supporting evidence and the ordinary review/integration process.
|
||||
|
||||
The existing backlog remains the sole task queue. Handbooks may link to tasks
|
||||
and domain coordination summaries but must not become competing task ledgers.
|
||||
|
||||
## Storage and adoption
|
||||
|
||||
The repository is the source of truth; Console indexes and search results are
|
||||
rebuildable projections, never independent authoritative copies. Documentation
|
||||
must remain usable offline and without TORCH. Do not require a hosted service,
|
||||
particular AI provider or external documentation platform.
|
||||
|
||||
During init/analyze, discover existing documentation and propose a project-fit
|
||||
navigation and ownership map. Reuse existing directories and conventions rather
|
||||
than silently moving documents. For projects without conventions, a candidate
|
||||
layout is `docs/knowledge/`, `docs/domains/<domain>/` and a clearly separated
|
||||
working-memory area. Exact paths and metadata format need a reviewed implementation
|
||||
contract; the layout is not a mandate for every project.
|
||||
|
||||
One accountable domain owns each reference page; collaborators and reviewers may
|
||||
be named separately. Cross-domain interface documentation retains one owner and
|
||||
review links to affected domains. Domain retirement, merge, split or reassignment
|
||||
must preserve history and transfer responsibility, not delete institutional memory.
|
||||
|
||||
## Agent use and maintenance
|
||||
|
||||
Startup/resume briefs expose the compact domain index and essential references,
|
||||
not the full KB. Agents retrieve relevant detail on demand and can discover
|
||||
cross-domain references without loading unrelated documentation into every context.
|
||||
Record which revision was supplied; warn about stale references where detectable.
|
||||
|
||||
Completion of a behavior, interface or workflow change requires documentation
|
||||
impact to be considered: update affected pages or record why no update is needed.
|
||||
Do not make clerical documentation edits a permission to change another domain's
|
||||
implementation. Route cross-domain documentation changes through existing ownership
|
||||
and review mechanisms and notify affected owners.
|
||||
|
||||
Reference pages expose owner, status (draft/experimental/reference/deprecated),
|
||||
verification evidence and exact last-verified commit. A timestamp or successful
|
||||
render does not prove accuracy. Branch/candidate documentation must not masquerade
|
||||
as landed canonical behavior; represent divergence and unknown freshness honestly.
|
||||
Flag broken links, missing owners and affected pages needing re-verification.
|
||||
Do not automatically certify pages, mass-rewrite them or invent a freshness score.
|
||||
|
||||
## Console Knowledge view
|
||||
|
||||
Add a dedicated Knowledge screen, not another long Overview section. Provide
|
||||
Read-the-Docs-style sidebar navigation, search, breadcrumbs, readable articles,
|
||||
stable deep links, responsive/mobile navigation and keyboard accessibility.
|
||||
Show owner, status, verified revision, related domains, tasks, pipeline definitions
|
||||
and history. Keep handbook, reference and provisional memory visibly distinct.
|
||||
|
||||
Offer bounded actions to ask the responsible domain and report outdated content
|
||||
through existing durable conversations/intake. Show queued/delivered outcomes;
|
||||
these actions must not wake agents while paused or silently grant edit authority.
|
||||
Use the same renderer for live project documentation and labelled demo fixtures.
|
||||
Do not bundle private project documents into the public marketing demo.
|
||||
|
||||
## Safety and portability
|
||||
|
||||
Render repository text as untrusted content: sanitize Markdown, block script/raw
|
||||
HTML execution, unsafe URL schemes, path traversal and escaping symlinks. Restrict
|
||||
indexing/serving to approved documentation roots. Exclude credentials and private
|
||||
working-memory content according to explicit project visibility policy, including
|
||||
search snippets and cached indexes. External links/imported instructions do not
|
||||
override TORCH policy. KB content does not expand operational authority.
|
||||
|
||||
## Required acceptance scenarios
|
||||
|
||||
- Existing project docs are discovered and linked without moves or overwrite;
|
||||
an empty project receives only a reviewed, project-fit plan.
|
||||
- Restart or provider replacement inherits the same domain handbook; unrelated
|
||||
KB bodies are not injected into its startup context.
|
||||
- A shared interface change updates its single authoritative page and routes
|
||||
affected-domain notification; unchanged pages are not falsely re-verified.
|
||||
- A domain ownership change preserves document history and transfers maintenance
|
||||
responsibility without creating two authoritative copies.
|
||||
- Provisional claims and candidate-branch behavior remain distinct from verified
|
||||
reference docs; missing evidence is visible rather than converted into confidence.
|
||||
- Navigation, search, deep links, keyboard and mobile use work on a realistic
|
||||
multi-domain project; actual browser inspection is required before UI acceptance.
|
||||
- Unsafe Markdown, traversal, symlink escape and private search leakage fail closed.
|
||||
- Questions/corrections are durable while paused; no provider launch occurs.
|
||||
- Markdown remains readable without TORCH, and the public demo has no private docs
|
||||
or live control-plane calls.
|
||||
|
||||
Implementation slices are recorded in the existing authoritative TORCH backlog.
|
||||
They remain proposed and unassigned for Session Manager triage after owner resume.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Bounded self-healing qualification
|
||||
|
||||
Owner approved this priority on 2026-10-03. This extends the adaptive-company
|
||||
plan and existing authoritative backlog; it is not a second work queue.
|
||||
|
||||
## Outcome
|
||||
|
||||
TORCH recovers authorized interrupted work without external babysitting. It
|
||||
preserves unfinished work, assignments and evidence, never duplicates executors,
|
||||
and escalates a genuine decision once with an actionable explanation. Managers
|
||||
resolve coordination within delegated authority; deterministic machinery must
|
||||
also detect manager/controller failure without relying on the failed agent.
|
||||
|
||||
## Execution order and existing owners
|
||||
|
||||
1. Provider Runtime / Kernel: finish `TASK-company-runtime-recovery`, including
|
||||
exact stopped-process proof, retained guard/controller-lock recovery, fresh
|
||||
instructions and preservation of dirty work. Qualification and native source
|
||||
adoption/integration remain prerequisites, not gates to bypass.
|
||||
2. Release / Runtime / Kernel: finish `TASK-conversation-persistent-operations`;
|
||||
installer-owned services survive reboot, reconcile idempotently and uninstall
|
||||
only manifest-owned resources. Keep dormant-install and explicit activation.
|
||||
3. Runtime / QA: prioritize the failure/backoff portion of
|
||||
`TASK-conversation-usage-governor`. Distinguish transient failure, quota,
|
||||
authentication, unavailable model, unknown outcome and owner pause. Circuit
|
||||
breakers must suppress repeated launches for the same unresolved cause.
|
||||
4. Kernel / Work: complete the authority/event prerequisites and
|
||||
`TASK-company-reconciler`, with existing `TASK-conversation-event-driven-wakes`
|
||||
and `TASK-conversation-queue-watchdog`. Observe, reserve, recheck authority
|
||||
and revision, execute one bounded action, record and reconcile. No duplicate
|
||||
grant, event, task, messaging or recovery stores.
|
||||
5. Manager / Console / QA: report the current wait owner, reason, age, last
|
||||
recovery attempt, next eligible action and exact evidence. Chase dependencies
|
||||
and approvals without inventing permission or sending repeated notices.
|
||||
|
||||
Urgent recovery does not preempt a running gate or steal an assignment. Managers
|
||||
should resolve existing test/file-boundary handoffs and assign QA-owned tests
|
||||
explicitly rather than circulate the same approval request indefinitely.
|
||||
|
||||
## Failure-injection acceptance matrix
|
||||
|
||||
Run against isolated fixtures and then an installed, owner-approved qualification
|
||||
project. Do not intentionally kill the live development fleet to prove recovery.
|
||||
|
||||
| Fault | Required observable outcome |
|
||||
| --- | --- |
|
||||
| Specialist exits mid-task | Retain task/session/evidence and dirty bytes; prove old process stopped before one authorized resume |
|
||||
| Process alive but quiet | Inspect tools/check queues and progress evidence; quiet alone never permits kill or guard release |
|
||||
| Manager exits | Independent deterministic controller discovers the loss and performs one policy-authorized resume |
|
||||
| Controller crashes after reservation | Reconcile exact invocation and durable reservation; no lost or duplicate execution |
|
||||
| Crash leaves turn/controller lock | Recover only with exact ownership/process proof; unknown or foreign state fails closed |
|
||||
| Reboot or service restart | Restore approved desired state once; preserve explicit manual pause and detached status |
|
||||
| Missed/duplicate/out-of-order notification | Periodic reconciliation finds the wait; duplicates and stale revisions cannot repeat effects |
|
||||
| Dependency or approval wait | Route to correct authority, record acknowledgement and escalate overdue wait without self-approval |
|
||||
| Check/integration conflict | Preserve failed evidence and candidate; return actionable work to its owner without relaxing checks |
|
||||
| Transient provider fault | Bounded retries with persisted backoff; no immediate unbounded restart loop |
|
||||
| Quota/auth/model unavailable | Persist scoped hold/circuit breaker; report once, no paid fallback or unrelated-provider switch |
|
||||
| Pause or authority revoked during recovery | Recheck immediately before effect and stop further launches |
|
||||
|
||||
Every accepted case needs exact candidate, controlled conditions, observable
|
||||
assertions, original failure evidence and independent QA. Record recovery latency,
|
||||
duplicate launches, lost work, retry count and owner interventions. A running
|
||||
process, green command, task-state churn or acknowledgement is not itself useful
|
||||
delivery. Finish with verified integration and next-work continuation.
|
||||
|
||||
## Current spending policy
|
||||
|
||||
The owner explicitly enabled unlimited automatic daily turns in alpha.4.2;
|
||||
missing subscription telemetry must remain unknown, not become a false credit
|
||||
estimate. Do not silently restore a finite cap as part of governor work. This
|
||||
specific authorization supersedes older pilot cap instructions, not approval,
|
||||
ownership, concurrency, no-progress or provider-failure boundaries. Unlimited
|
||||
eligible work is not unlimited retries. No paid API fallback was authorized.
|
||||
|
||||
## Completion boundary
|
||||
|
||||
Do not close recovery/reconciler umbrellas from focused source tests alone.
|
||||
Require installed-runtime failure injection, independent QA, reboot/pause
|
||||
qualification and an observed unattended work cycle. Report remaining limitations
|
||||
plainly; intentional owner decisions may remain blocked, forgotten handoffs may
|
||||
not. No new permanent supervisor is required merely to implement this loop.
|
||||
@@ -1,5 +1,172 @@
|
||||
# Test Integrity Notes
|
||||
|
||||
## 2026-10-03 — Current unread inbox startup mitigation
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: new_tests
|
||||
scenarios:
|
||||
- id: SCN-fleet-startup-inbox
|
||||
given: "Manager and specialist each have 102 unread handoffs, exceeding the legacy default history page"
|
||||
when: "Fresh and resumed provider launch messages specify the current unread-inbox request and it is invoked through the registered identity-bound tool"
|
||||
then: "Both launches name the correct identity and retrieve newer messages absent from default history, without acknowledging them or weakening authority; overflow coverage is explicitly uncertain"
|
||||
observable_outcomes:
|
||||
- "Actual provider launch arguments, registered MCP response, newest message identity and unchanged unread count"
|
||||
determinism_controls:
|
||||
- "Hermetic project/worktrees, increasing fixed clock and real local tool; no provider launches"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Prompt-only proof that never exercises the actual inbox tool"
|
||||
- "Claiming truncated history is complete or reading acknowledges a handoff"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
## 2026-10-03 — Audited one-day additional allowance
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: new_tests
|
||||
scenarios:
|
||||
- id: SCN-runtime-continuation-day-override
|
||||
given: "An exhausted owner-approved daily allowance and retained charged attempts"
|
||||
when: "The owner approves a bounded current-UTC-day total and later pauses or the day rolls over"
|
||||
then: "Extra work starts without resetting attempts; non-owner/invalid overrides refuse; pause persists; the following day uses the original daily cap"
|
||||
observable_outcomes:
|
||||
- "Actual launched count, charged attempts, remaining allowance, owner audit and next-day limit"
|
||||
determinism_controls:
|
||||
- "Hermetic project, fixed clock and controlled launch callback; no live provider or host scheduling"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Erasing usage or permanently removing daily caps on an owner override"
|
||||
- "Allowing a specialist to authorize extra budget or bypass pause"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
## 2026-10-03 — Owner-configured concurrency
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: new_tests
|
||||
scenarios:
|
||||
- id: SCN-runtime-continuation-concurrency
|
||||
given: "Seven eligible identities and an approved 48-turn daily cap"
|
||||
when: "The owner raises concurrency to seven, starts a bounded controller, then pauses"
|
||||
then: "Seven pending starts occur before completion; a tighter limit restricts candidates; invalid settings refuse; pause preserves cap, concurrency and charged attempts"
|
||||
observable_outcomes:
|
||||
- "Pending launch count, policy capacity, ledger attempts and refusal codes"
|
||||
determinism_controls:
|
||||
- "Hermetic Git repository, fixed clock, controlled promises and no provider calls"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Treating sequential starts as concurrency or resetting usage on configuration"
|
||||
- "Tick limits exceeding owner-approved policy"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
## 2026-10-03 — Visible automatic-work stops
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: new_tests
|
||||
scenarios:
|
||||
- id: SCN-runtime-continuation-health
|
||||
given: "An enabled pilot exhausts its daily attempt allowance"
|
||||
when: "Observation reads the ledger, next UTC day, pause or malformed state"
|
||||
then: "The exact stop and remaining allowance are visible without resetting or modifying state, and no timer/token qualification is fabricated"
|
||||
- id: SCN-console-continuation-stop
|
||||
given: "A daily cap stop and a specialist no-progress hold"
|
||||
when: "Attention items are projected"
|
||||
then: "The budget decision belongs to the owner, coordination belongs to the manager, and no fake approval action or automatic budget increase is offered"
|
||||
observable_outcomes:
|
||||
- "Live snapshot health, byte-identical ledger and owner/manager attention routing"
|
||||
determinism_controls:
|
||||
- "Hermetic repository, fixed UTC clock, injected executor and plain snapshot fixtures"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Silent budget exhaustion disguised as healthy idle"
|
||||
- "Resetting a ledger to make work appear available"
|
||||
- "Treating declared policy as a verified running timer or measured usage"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
## 2026-10-03 — Rolling continuation and bounded no-progress escalation
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: new_tests
|
||||
scenarios:
|
||||
- id: SCN-runtime-continuation-refill
|
||||
given: "A long manager turn and a short specialist turn occupy two slots; a third identity waits"
|
||||
when: "Only the short turn completes"
|
||||
then: "The third starts before manager completion and the daily attempt cap still holds"
|
||||
- id: SCN-runtime-continuation-no-progress
|
||||
given: "An unchanged unfinished eligible assignment"
|
||||
when: "Two successful executor turns make no recorded task progress"
|
||||
then: "Exactly two specialist turns occur, one durable coordination request is sent, unchanged-work repeats stop, new revisions may resume and unmet dependencies remain ineligible"
|
||||
- id: SCN-runtime-continuation-capacity
|
||||
given: "An owner-started executor already occupies the configured automatic capacity"
|
||||
when: "The continuation controller runs"
|
||||
then: "No additional automatic turn starts and no attempt is charged"
|
||||
- id: SCN-runtime-continuation-fairness
|
||||
given: "One specialist keeps advancing revisions while another eligible identity has not started"
|
||||
when: "A slot becomes available"
|
||||
then: "The least-recently-dispatched peer starts before the returning specialist"
|
||||
observable_outcomes:
|
||||
- "Actual launch ordering, daily charges, coordination messages and explicit held-work reason"
|
||||
determinism_controls:
|
||||
- "Hermetic installed Git projects, manually resolved provider promises and task fixtures; no sleep, network or live model"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Replacing the batch barrier by raising concurrency"
|
||||
- "Claiming refill only after all turns finish"
|
||||
- "Spending unlimited turns on unchanged work or ignoring dependency gates"
|
||||
- "Ignoring physical executors started outside the controller"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
## 2026-10-03 — Process-verified runtime presence
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: new_tests
|
||||
scenarios:
|
||||
- id: SCN-runtime-live-presence
|
||||
given: "A Linux executor holds a real identity guard while durable state is idle"
|
||||
when: "The observation API reads starting, working, approval waiting and terminal phases"
|
||||
then: "Live phases display accurately, semantic waits persist, terminal guards vanish and durable reports are not fabricated"
|
||||
- id: SCN-runtime-live-presence-refusal
|
||||
given: "A guarded identity with injected OS observations"
|
||||
when: "PID is replaced, dead, foreign-user, unavailable or project binding is wrong"
|
||||
then: "It never reports active execution"
|
||||
- id: SCN-runtime-live-presence-legacy
|
||||
given: "A pre-observation guard without a process-start token"
|
||||
when: "The exact TORCH up identity and canonical cwd match or disagree"
|
||||
then: "Only a verified matching live executor displays working"
|
||||
observable_outcomes:
|
||||
- "Observation API state, reportedState, executor source and unchanged durable identity"
|
||||
determinism_controls:
|
||||
- "Hermetic installed repository and injected PID observations for refusal cases"
|
||||
- "Supplemental Linux /proc integration observes the test process itself, with no provider or timing sleeps"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Painting all idle badges working without process evidence"
|
||||
- "Treating a crashed guard or reused PID as an active agent"
|
||||
- "Hiding approval waits or fabricating heartbeat/task progress"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
## 2026-09-30 — Bounded routine coordination
|
||||
|
||||
```yaml
|
||||
@@ -2716,6 +2883,45 @@ test_integrity_note:
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
# Candidate source B2 frozen-input and fence boundaries (2026-10-01)
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: new_tests
|
||||
scenarios:
|
||||
- id: SCN-candidate-bootstrap-one-shot
|
||||
given: "An issued, nonce-bound native admission"
|
||||
when: "It is opened, replayed, expired, foreign, or malformed"
|
||||
then: "Only one bounded private bootstrap opens; every invalid case refuses before child start and exposes no authority handle"
|
||||
- id: SCN-candidate-input-consumption
|
||||
given: "A frozen project-local source snapshot"
|
||||
when: "The original source changes before the fixed Node child reads its cwd"
|
||||
then: "The child observes the captured bytes; snapshot drift refuses"
|
||||
- id: SCN-candidate-execution-boundary
|
||||
given: "A fixed command and explicit bounded environment"
|
||||
when: "Ambient PATH/HOME/cache fallback or output overflow is attempted"
|
||||
then: "Execution refuses or remains non-success"
|
||||
- id: SCN-candidate-fence-closure
|
||||
given: "A private PID1 namespace observation"
|
||||
when: "Descendant closure is absent or uncertain"
|
||||
then: "The outcome is UNKNOWN/INCOMPLETE, never terminal PASS"
|
||||
observable_outcomes:
|
||||
- "No DB, ControlPlane, writer, receipt adapter, or authority FD reaches the child"
|
||||
- "Product input links and special nodes refuse; frozen product S stays under 64MiB/file and 512MiB total"
|
||||
- "Artifact output remains M/artifactManifest; receipt-adapter A is absent and unclaimed"
|
||||
determinism_controls:
|
||||
- "Temporary source trees, fixed Node executable, explicit empty ambient environment, bounded streams, and injected observations"
|
||||
- "No live policy registration, installation, network, cache, or provider process"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Reading mutated live source instead of captured bytes"
|
||||
- "Replaying a private admission or relabeling fixture output as a receipt"
|
||||
- "Treating PGID emptiness, zero exit, or Chromium metadata as descendant/binary proof"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
# Console attention advisory cohorts (2026-10-01)
|
||||
|
||||
```yaml
|
||||
@@ -2765,6 +2971,47 @@ test_integrity_note:
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
# Candidate source B2 authenticated-attempt spec correction (2026-10-01)
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: spec_correction
|
||||
scenarios:
|
||||
- id: SCN-candidate-bootstrap-one-shot
|
||||
given: "A CheckService-issued opaque registered-plan handle"
|
||||
when: "A foreign, cloned, expired, replayed, or already-closed handle is used"
|
||||
then: "No FD3 admission opens; the only accepted acknowledgement is the exact FD4 binding frame and it closes the original linear handle"
|
||||
- id: SCN-candidate-input-consumption
|
||||
given: "A frozen, bounded product snapshot and a fixed Node E binding"
|
||||
when: "The original product bytes change before execution"
|
||||
then: "An actual spawned child reads the frozen bytes; post-capture drift refuses before spawn"
|
||||
- id: SCN-candidate-execution-boundary
|
||||
given: "An opaque attempt with fixed command, arguments, explicit environment, and output cap"
|
||||
when: "The child exits zero after exceeding the cap"
|
||||
then: "The parent records overflow as non-success without accepting an injected executor or caller-selected limit"
|
||||
- id: SCN-candidate-fence-closure
|
||||
given: "A closed opaque attempt"
|
||||
when: "The backend has not recorded namespace identity, FD closure, and descendant reaping"
|
||||
then: "Terminal proof is UNKNOWN; fabricated observations and cloned handles cannot create proof"
|
||||
observable_outcomes:
|
||||
- "Only the exact FD4 acknowledgement closes a one-shot opaque admission"
|
||||
- "Child stdout proves frozen-byte consumption, not same-process test reads"
|
||||
- "Zero exit plus output overflow is never success"
|
||||
- "Missing backend fence evidence is never TerminalProof"
|
||||
determinism_controls:
|
||||
- "Temporary source trees, fixed absolute Node runtime, explicit empty child environment, bounded streams"
|
||||
- "No injected executor, caller-provided command, root, environment, limit, or terminal observation"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Arbitrary tuple issuance or a fabricated bootstrap literal"
|
||||
- "Boolean caller observations minting terminal proof"
|
||||
- "Cloned or reused handles reopening an admission"
|
||||
- "Same-process snapshot reads impersonating child consumption"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
# Compact Console Overview density (2026-10-01)
|
||||
|
||||
```yaml
|
||||
@@ -2811,6 +3058,257 @@ test_integrity_note:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
# Owner-requested legacy recovery and bounded continuation (2026-10-03)
|
||||
|
||||
Implementation and behavioral basis:
|
||||
[legacy runtime recovery](spec_changes/2026-10-03-legacy-runtime-recovery.md).
|
||||
Existing assertions are unchanged; the new scenarios are in
|
||||
`test/fleet/legacy-runtime-recovery.test.mjs`.
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: new_tests
|
||||
scenarios:
|
||||
- id: SCN-legacy-runtime-recovery
|
||||
given: "A stopped exact invocation and retained unknown identity"
|
||||
when: "The owner recovers presence"
|
||||
then: "One owner audit, offline presence and preserved task/session/unknown"
|
||||
- id: SCN-legacy-runtime-recovery-refusal
|
||||
given: "Live or mismatched invocation/process evidence"
|
||||
when: "Proof is requested"
|
||||
then: "Recovery refuses without mutation"
|
||||
- id: SCN-legacy-runtime-recovery-cas
|
||||
given: "Stale, expired, forged or newly live evidence"
|
||||
when: "Recovery applies"
|
||||
then: "Transaction refuses without success audit"
|
||||
- id: SCN-legacy-runtime-recovery-authority
|
||||
given: "A specialist or incomplete expected snapshot"
|
||||
when: "Owner recovery is attempted"
|
||||
then: "Authority/snapshot validation refuses"
|
||||
- id: SCN-runtime-turn-guard
|
||||
given: "A physically live executor reporting idle"
|
||||
when: "A second turn tries to start"
|
||||
then: "The executor mutex refuses overlap until terminal release"
|
||||
- id: SCN-runtime-continuation
|
||||
given: "Owner-enabled roster and a new durable signal"
|
||||
when: "Turns repeat, hit a cap or are paused"
|
||||
then: "Only new eligible signals launch within the owner daily cap"
|
||||
- id: SCN-runtime-continuation-signals
|
||||
given: "Two eligible identities with multiple unread messages"
|
||||
when: "A bounded batch starts and only acknowledgements change afterward"
|
||||
then: "Both starts occur concurrently, a competing controller refuses, and acknowledgements alone cause no wake"
|
||||
- id: SCN-runtime-continuation-pause
|
||||
given: "Two eligible turns"
|
||||
when: "The owner pauses during the first"
|
||||
then: "The second does not start and pause is retained"
|
||||
- id: SCN-legacy-runtime-recovery-project
|
||||
given: "Cloned project/session metadata in distinct checkouts"
|
||||
when: "Proof crosses checkout boundaries"
|
||||
then: "Recovery refuses and leaves the other identity untouched"
|
||||
observable_outcomes:
|
||||
- "Persisted identity, task/session, owner audit, launch count and refusal codes"
|
||||
determinism_controls:
|
||||
- "Hermetic repositories, fixed clock and OS-boundary fixtures; no live provider"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Boolean stopped proof or stop-as-PASS"
|
||||
- "Stale/cross-project mutations and peer impersonation"
|
||||
- "Duplicate physical turns or unchanged-signal busy loops"
|
||||
- "Losing pause or refunding interrupted attempts"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: new_tests
|
||||
scenarios:
|
||||
- id: SCN-codex-mcp-runtime-dir
|
||||
given: "A Codex adapter receives an injected Linux parent environment with a valid XDG runtime directory, or deterministic missing, malformed, or unsupported-platform context"
|
||||
when: "It creates or resumes an identity-bound Codex session with a TORCH MCP server"
|
||||
then: "Both launch records name only XDG_RUNTIME_DIR in MCP env_vars for valid context; invalid context emits no env_vars, and no runtime value, DBUS, home, path, token, or general environment is serialized"
|
||||
observable_outcomes:
|
||||
- "Create and resume plans expose the exact single-name MCP env_vars allowlist only for valid parent context"
|
||||
- "Invalid or unsupported context omits the allowlist without synthesizing a runtime path"
|
||||
- "The serialized invocation never exposes parent environment values"
|
||||
determinism_controls:
|
||||
- "Narrow injected adapter env and platform inputs; no process-wide environment mutation, host service, provider, or native check"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Hard-coded runtime UID or directory values"
|
||||
- "Forwarding DBUS, secrets, home, PATH, or the complete parent environment"
|
||||
- "Treating a mocked launch record as native runtime qualification"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
# Exact-source adoption (2026-10-03)
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: new_tests
|
||||
scenarios:
|
||||
- id: SCN-integration-adoption-private-ref-cas
|
||||
given: "A clean disposable Git repository, a base commit, and an owner-bound private-ref adoption"
|
||||
when: "The adoption finalizes and a stale compare-and-swap attempts to alter its candidate ref"
|
||||
then: "Real Git private archive and candidate refs are created atomically, while the stale write refuses"
|
||||
- id: SCN-integration-adoption-journal-and-binding
|
||||
given: "A prepared adoption journal record"
|
||||
when: "A second preparation or lifecycle request supplies a different candidate binding"
|
||||
then: "The journal revision compare-and-swap and immutable binding checks refuse without changing the prepared record"
|
||||
- id: SCN-integration-adoption-finalize-recovery
|
||||
given: "A real private-ref transaction completed before its journal write"
|
||||
when: "Reconciliation is run and then replayed"
|
||||
then: "One durable finalized recovery is recorded and the replay is consistent without another mutation"
|
||||
- id: SCN-integration-adoption-rollback-recovery
|
||||
given: "A finalized candidate whose private ref was removed before its rollback journal write, or whose candidate ref diverged"
|
||||
when: "Rollback is requested"
|
||||
then: "The completed rollback is recovered once and divergent candidate bytes refuse"
|
||||
- id: SCN-integration-adoption-private-ref-intake
|
||||
given: "An existing unfinalized private candidate ref and a separate finalized owner-bound private candidate ref"
|
||||
when: "Direct private-ref intake and authenticated native qualification are requested"
|
||||
then: "Direct intake refuses without a queue record, while the authenticated path records the exact private ref and commit in ordinary testing state"
|
||||
- id: SCN-integration-adoption-approval-boundaries
|
||||
given: "An ordinary source-only approval, or an adoption whose actor, action, approval revision, or provenance differs from the approved immutable binding"
|
||||
when: "Preparation or a lifecycle transition is attempted"
|
||||
then: "The transition refuses with its structured boundary code and no integration queue record"
|
||||
- id: SCN-integration-adoption-request-replay
|
||||
given: "A finalized adoption that has created its one ordinary private-ref integration request"
|
||||
when: "Two independently opened authenticated API callers request it concurrently"
|
||||
then: "The existing transactional adoption journal serializes them, one caller refuses after the completed transition, and exactly one queue row remains"
|
||||
- id: SCN-integration-adoption-legacy-retries
|
||||
given: "An existing project database containing two historical ordinary-request retry rows with the same source branch and commit"
|
||||
when: "IntegrationService starts"
|
||||
then: "Both records remain readable; startup adds no global uniqueness constraint and does not reject historical evidence"
|
||||
observable_outcomes:
|
||||
- "Git ref values, structured refusal codes, durable journal state, and native integration-request fields"
|
||||
determinism_controls:
|
||||
- "Disposable local Git repositories, temporary SQLite-backed control-plane state, fixed fixture bytes, and no network or provider"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Fixture-only CAS implementations that do not exercise Git update-ref"
|
||||
- "Mutable lifecycle bindings or overwriteable adoption journal records"
|
||||
- "Claiming crash recovery without preserving actual ref effects"
|
||||
- "Treating a private candidate as an ordinary specialist branch"
|
||||
- "Using an unrelated, source-only, stale, or mismatched approval as adoption authority"
|
||||
- "Duplicating a private-ref queue record through concurrent authenticated API requests or replay"
|
||||
- "Rejecting historical ordinary-request retry evidence with an unqualified schema constraint"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: new_tests
|
||||
scenarios:
|
||||
- id: SCN-integration-adoption-lifecycle-revision-cas
|
||||
given: "A prepared or finalized owner-bound adoption record with a known journal revision"
|
||||
when: "A caller supplies a stale or omitted expectedRecordRevision to finalize, reconcile, rollback, or request native qualification"
|
||||
then: "The operation refuses before private-ref, journal, queue, or audit effects; the original record and refs remain unchanged"
|
||||
observable_outcomes:
|
||||
- "ADOPTION_RECORD_REVISION_STALE is returned for stale or omitted lifecycle callers"
|
||||
- "Private candidate/archive refs, durable journal record, audit count, and integration queue remain unchanged for every rejected lifecycle call"
|
||||
determinism_controls:
|
||||
- "Disposable local Git repository and temporary SQLite-backed control-plane fixture; no network, provider, or live adoption"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Issuing external ref or queue effects from a stale lifecycle read"
|
||||
- "Treating journal revision as an internal-only implementation detail"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: spec_correction
|
||||
scenarios:
|
||||
- id: SCN-integration-adoption-request-replay
|
||||
given: "Two authenticated callers carry the same finalized adoption revision into the serialized native-request transition"
|
||||
when: "One caller completes the transition and the other resumes with its now-stale expectedRecordRevision"
|
||||
then: "The second caller strictly receives ADOPTION_RECORD_REVISION_STALE while exactly one requested journal record and one queue row remain"
|
||||
observable_outcomes:
|
||||
- "The replay loser reports the stale journal-revision boundary, not a generic post-state refusal"
|
||||
- "Exactly one private-ref integration request and requested audit transition exist"
|
||||
spec_correction_basis:
|
||||
- "The former ADOPTION_REQUEST_STATE_INVALID replay expectation was wrong: after the serialized winner commits, the loser carries an obsolete journal revision, so revision CAS is the first observable refusal boundary under SCN-integration-adoption-request-replay."
|
||||
determinism_controls:
|
||||
- "Two independently opened local API processes over disposable Git and SQLite fixture state"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Accepting a stale lifecycle read after another caller has committed"
|
||||
- "Relaxing the exactly-one-request invariant after adding revision binding"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
## 2026-10-03 — Schema-v2 read-only owner-reply boundary
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: new_tests
|
||||
scenarios:
|
||||
- id: SCN-read-only-v2-control-plane
|
||||
given: "A canonical schema-v2 fixture, including a write-refusing state directory"
|
||||
when: "A reader opens a normal SQLite read transaction and a mutation is attempted"
|
||||
then: "It returns one coherent snapshot or a typed refusal; no database or sidecar bytes change"
|
||||
- id: SCN-read-only-pinned-correlated-snapshot
|
||||
given: "Two v2 identities in one observable generation"
|
||||
when: "A writer atomically commits their next generation while the reader remains open"
|
||||
then: "The reader observes one generation or refuses, never a mixed pair"
|
||||
- id: SCN-owner-addressed-reply-durability
|
||||
given: "A registered agent has an owner-originated request addressed to it"
|
||||
when: "It sends a dedicated reply and state is reopened"
|
||||
then: "The reply linkage, bounded owner cursor, and actor audit persist"
|
||||
- id: SCN-owner-addressed-reply-refusal
|
||||
given: "Spoofed senders, a different valid registered agent, generic owner recipients, foreign/forged references, or a non-owner reader"
|
||||
when: "They invoke the owner-reply boundary"
|
||||
then: "They refuse without message or audit writes; the owner conversation remains unchanged; direct and broadcast delivery remain intact"
|
||||
observable_outcomes:
|
||||
- "Schema-v2 compatibility refusal, stable snapshot rows, byte/sidecar preservation, durable reply linkage, cursor page, and audit event"
|
||||
determinism_controls:
|
||||
- "Hermetic Git/SQLite fixtures, fixed clocks and IDs, explicit transactions, no network, provider, sleep, or retry"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Migrating or initializing a read-only or unknown schema"
|
||||
- "Returning stale or mixed identity generations"
|
||||
- "Treating owner as a fleet identity or accepting generic recipient bypasses"
|
||||
- "Accepting foreign, forged, or valid-identity wrong-addressee owner-reply linkage"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
## 2026-10-03 — Public MCP owner-reply wrapper
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: new_tests
|
||||
scenarios:
|
||||
- id: SCN-mcp-owner-reply-public-boundary
|
||||
given: "A registered agent has an owner-originated request addressed to it and a second registered identity exists"
|
||||
when: "Each identity invokes the public stdio MCP owner-reply tool"
|
||||
then: "Only the bound addressee can persist a reply with replyTo and audit attribution; selector fields and another valid identity refuse before a write"
|
||||
observable_outcomes:
|
||||
- "The MCP schema exposes only request_id and body with no additional properties or owner-history tool"
|
||||
- "The persisted reply is linked to the owner request and audited as the bound actor"
|
||||
- "Selector-field and wrong-addressee refusals leave owner conversation and audit counts unchanged"
|
||||
determinism_controls:
|
||||
- "Hermetic Git/SQLite fixture, fixed topology, local stdio MCP process, no network, provider, sleep, or retry"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Selecting a sender, recipient, owner, or history through MCP input"
|
||||
- "Replying from a different valid Fleet identity"
|
||||
- "Exposing an agent-facing owner-history reader"
|
||||
- "Claiming an in-process helper as public MCP qualification"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
# Self-host runtime metadata observations (2026-10-01)
|
||||
|
||||
|
||||
+128
@@ -1,5 +1,133 @@
|
||||
# TORCH remaining work
|
||||
|
||||
## Immediate owner-facing milestone: agent Chat (2026-10-03)
|
||||
|
||||
Owner promoted `TASK-owner-agent-chat`, `TASK-console-agent-chat-ui` and
|
||||
`TASK-console-chat-cards-qualification` to urgent. Deliver default coordinator
|
||||
chat, activated-agent switching, durable history and a safe composer first,
|
||||
following [the Chat specification](CONSOLE_CHAT_AND_COMPACT_CARDS_SPEC.md).
|
||||
Manager must triage eligible slices; priority does not remove runtime-wake
|
||||
dependencies or preempt active work. Recovery unblockers continue alongside it.
|
||||
|
||||
## Owner-approved self-healing priority (2026-10-03)
|
||||
|
||||
Owner also approved [operational flow and Ops pilot improvements](OPS_FLOW_IMPROVEMENT_SPEC.md):
|
||||
two focused incident-resolution/adoption-and-QA-handoff requests, additive Chat
|
||||
and evidence acceptance, and an existing-identity Ops responsibility pilot. No
|
||||
eighth standing agent or overlapping implementation ownership is implied.
|
||||
|
||||
Authoritative urgent proposals (assignment remains with Session Manager):
|
||||
|
||||
- [ ] `TASK-fe86062d-847f-46ab-9ad9-d721113e5dcd`: operational incident ownership
|
||||
and overdue wait resolution, extending current messages/events/approvals.
|
||||
- [ ] `TASK-f40126ee-2485-45c8-a67b-4279e4e8704a`: supported exact-source
|
||||
adoption and named QA/test handoffs through native qualification/integration.
|
||||
|
||||
The linked spec supplies additive acceptance for existing Chat/evidence tasks
|
||||
and the Ops responsibility pilot; task-file criteria have not been rewritten by
|
||||
the owner because the installed API has no supported amendment operation.
|
||||
|
||||
Follow [bounded self-healing qualification](SELF_HEALING_QUALIFICATION_PLAN.md).
|
||||
Prioritize the existing runtime-recovery, persistent-operations, provider-failure
|
||||
governor, event/reconciler and queue-watchdog work without duplicating their
|
||||
backlog records or bypassing dependencies. Require installed failure-injection
|
||||
evidence and independent QA, not merely active executors. The owner subsequently
|
||||
enabled unlimited daily continuation in alpha.4.2; older cap/resume statements
|
||||
below are historical and do not override that specific authorization. Retry
|
||||
circuit breakers, concurrency and approval/ownership gates still apply.
|
||||
|
||||
## Current owner priority: reliability first (2026-10-03 UTC)
|
||||
|
||||
End-to-end repair qualification is still open. Runtime reported exact converged
|
||||
candidate `d2d9845` with four fresh native check PASS receipts; QA independent
|
||||
acceptance is not yet recorded. Owner source fixes cover guarded presence,
|
||||
rolling/fair dispatch, no-progress escalation and visible daily-cap stops. The
|
||||
first automatic allowance of 12 turns was exhausted; any increase needs an
|
||||
explicit owner choice, not a manual-start bypass. Next dispatch should prioritize
|
||||
QA's exact verdict/support decisions, Release's declared-inventory consumer
|
||||
decision, safe Kernel/Work composition and actual Console implementation.
|
||||
Require evidence of specialist work, independent review/checks, serialized
|
||||
integration and next-work continuation before calling the autonomous loop ready.
|
||||
|
||||
Live all-fleet resume exposed a new urgent proposal:
|
||||
`TASK-d95e72d0-816d-4fc2-acaf-b73642fd4881` — refill continuation capacity
|
||||
when individual turns exit instead of waiting for the longest batch member.
|
||||
Manager remained running after Kernel/Runtime exited, leaving free slots unused
|
||||
and delaying the other four specialists. Preserve identity guards, pause, global
|
||||
capacity and attempt accounting when replacing the batch barrier.
|
||||
The same run reconfirmed the existing `TASK-pilot-starting-presence`: actual
|
||||
executors can display idle during startup. Do not duplicate that task or falsify
|
||||
presence to make the board look busy. Owner explicitly requested one simultaneous
|
||||
resume of all seven existing identities; that manual batch is separate from the
|
||||
automatic pilot's 12-turn daily ledger, not proof of a global usage governor.
|
||||
|
||||
This ordering supersedes the UI-first ordering below. The existing authoritative
|
||||
backlog, not this index, controls task state and assignment.
|
||||
|
||||
1. Safe current-schema runtime recovery (`TASK-company-runtime-recovery`) and
|
||||
combined CLI qualification (`TASK-pilot-cli-suite-timeout`).
|
||||
2. Truthful startup/session status (`TASK-pilot-starting-presence`) and its
|
||||
existing Kernel reservation/Work candidate-safety prerequisites.
|
||||
3. Persistent operations and bounded event-driven continuation
|
||||
(`TASK-conversation-persistent-operations`,
|
||||
`TASK-conversation-event-driven-wakes`), followed by actual scheduler
|
||||
qualification (`TASK-conversation-scheduler-qualification`).
|
||||
4. Specific actionable operational warnings (`TASK-console-attention-actions`).
|
||||
5. Compact cards/detail modals and coordinator-first Chat remain high priority.
|
||||
|
||||
The first four groups and incomplete technical prerequisites are urgent;
|
||||
scheduler qualification is high. These priority-only changes preserve owners,
|
||||
states, dependencies and evidence. Blocked remains blocked; automatic wakes stay
|
||||
disabled until qualified and explicitly activated. Older pause/install/status
|
||||
statements below are historical snapshots, not current runtime evidence.
|
||||
|
||||
Owner-requested recovery/continuation implementation is described in
|
||||
[the source pilot specification](spec_changes/2026-10-03-legacy-runtime-recovery.md).
|
||||
Do not close the runtime-recovery, persistent-operations or event-wake umbrellas
|
||||
from focused tests alone. Release/Fleet Operations still need installer-owned
|
||||
permanent timer provisioning/removal, installed artifact qualification and restart
|
||||
recovery of retained crashed turn/controller guards. The transient host pilot does
|
||||
not survive reboot, and its daily turn cap is not a token/dollar usage governor.
|
||||
|
||||
## Priority UI cycle: compact cards and agent Chat
|
||||
|
||||
Owner-approved [Console UI spec](CONSOLE_CHAT_AND_COMPACT_CARDS_SPEC.md).
|
||||
Prioritize these after explicit owner resume, ahead of broad company expansion:
|
||||
`TASK-console-compact-details`, `TASK-console-agent-chat-ui` and
|
||||
`TASK-console-chat-cards-qualification`. Extend the existing high-priority
|
||||
`TASK-owner-agent-chat`; do not duplicate messaging or bypass paused wake policy.
|
||||
Proposals remain unassigned. Fleet startup is not authorized by this addition.
|
||||
|
||||
## Adaptive software company upgrade (owner-approved, 2026-10-01)
|
||||
|
||||
The [integration plan](ADAPTIVE_COMPANY_INTEGRATION_PLAN.md) records scope,
|
||||
existing-task reuse, phased contracts, exit gates and migration/rollback rules.
|
||||
The existing authoritative backlog contains eleven linked `TASK-company-`
|
||||
proposals, not a second queue. Runtime recovery gates unattended coordination;
|
||||
authority comes from scoped revocable grants, not reporting relationships.
|
||||
Existing grants, event-driven wakes, first-work intake, usage governance and
|
||||
hierarchy pilots are reused rather than replaced. All new items are proposed
|
||||
and unassigned. No automatic restart or implementation is authorized by this
|
||||
roadmap addition; the fleet remains manually paused pending owner resume.
|
||||
|
||||
## Owner-approved project knowledge requirement (2026-10-01)
|
||||
|
||||
See [Project knowledge and domain institutional memory](PROJECT_KNOWLEDGE_SPEC.md).
|
||||
The existing authoritative backlog holds the implementation slices below; this
|
||||
section is an index, not a second queue. All are proposed/unassigned, and the
|
||||
fleet remains manually paused until the owner explicitly requests resume.
|
||||
|
||||
- `TASK-knowledge-contract`: project-fit Git-backed KB, domain handbooks and
|
||||
provisional memory; metadata, visibility, evidence and ownership contract.
|
||||
- `TASK-knowledge-agent-context`: documentation discovery, compact domain brief
|
||||
references and provider-independent retrieval/inheritance without context bloat.
|
||||
- `TASK-knowledge-maintenance`: documentation-impact workflow, verification
|
||||
evidence, cross-domain notifications and ownership transfer without duplicate truth.
|
||||
- `TASK-knowledge-console`: dedicated accessible Knowledge view, safe rendering,
|
||||
search, history and durable ask-domain/report-outdated actions.
|
||||
- `TASK-knowledge-qualification`: end-to-end inheritance/freshness/privacy/pause
|
||||
scenarios and realistic browser acceptance, including isolated demo fixtures.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Owner-configured continuation concurrency
|
||||
|
||||
The owner can configure `torch continuation enable --concurrency 7 --yes`.
|
||||
The default remains three; valid settings are integers from one through seven.
|
||||
An explicit tick `--limit` may tighten but never exceed the approved policy.
|
||||
Each refill reads current policy; lowering capacity does not cancel active turns.
|
||||
Omitted configuration fields preserve the approved daily cap and concurrency.
|
||||
Configuration never resets the attempt ledger or bypasses physical identity guards.
|
||||
|
||||
An already-running older controller cannot hot-load this change. Replace its
|
||||
timer, not its active service, and allow its native turns and controller lock to
|
||||
finish before the new controller takes over. No concurrent controllers may
|
||||
dispatch through the same project lock.
|
||||
|
||||
Scenario SCN-runtime-continuation-concurrency proves seven simultaneous pending
|
||||
starts, tighter tick limits, invalid-policy refusal and preserved usage on pause.
|
||||
@@ -0,0 +1,14 @@
|
||||
# One-day continuation allowance override
|
||||
|
||||
An explicit owner decision can grant a higher bounded total for the current UTC
|
||||
day using `torch continuation enable --today-max-turns 96 --yes`.
|
||||
The override total includes turns already charged; it never erases usage.
|
||||
The ordinary daily cap (1–48) remains unchanged. The override ceiling is 96.
|
||||
At UTC midnight the override ceases to apply without a scheduled reset write.
|
||||
Owner-only audited configuration, seven-slot concurrency, identity guards,
|
||||
pause, unknown-outcome protection and all verification boundaries remain intact.
|
||||
Omitting the override argument preserves its existing same-day decision.
|
||||
This is a turn allowance, not a measured token, subscription-credit or cost limit.
|
||||
|
||||
SCN-runtime-continuation-day-override checks actual extra dispatch, preserved
|
||||
charges, authorization/range refusals, pause and automatic next-day expiry.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Manual owner recovery for retained unknown executor outcomes
|
||||
|
||||
The owner requested fixing the stalled fleet. Legacy runtime records lacked a
|
||||
recovery API: three physically stopped executors remained `working/unknown` and
|
||||
correctly refused duplicate launches. This change adds an owner-only, previewed
|
||||
`recover-runtime` operation without schema changes or automatic recovery.
|
||||
|
||||
The owner explicitly selects a project-scoped systemd service invocation. Its
|
||||
working directory and exact area argument must match this installation; its
|
||||
terminal timestamp must match the retained identity update within five seconds
|
||||
(systemd displays timestamps with one-second precision). Both service PIDs must
|
||||
be zero, its cgroup absent, and no visible same-user native process may reference
|
||||
the session or registered workspace. Physical state is checked again at apply.
|
||||
Unreadable process visibility, an active service, foreign invocation or changed
|
||||
identity refuses recovery. This is a **manual legacy operator binding**, not
|
||||
cryptographic session provenance or protection against arbitrary same-user code.
|
||||
|
||||
Evidence is process-local, branded, single-use and expires after sixty seconds.
|
||||
The service transaction compares the exact state/runtime/session/update snapshot,
|
||||
sets presence to offline and writes an owner-attributed audit. Session, current
|
||||
task and the unknown outcome are preserved. No task, test or launch is completed;
|
||||
no process is killed, lease removed, schema migrated or provider started.
|
||||
|
||||
Preview and apply use the same implementation. Apply additionally requires the
|
||||
session and update timestamp from the preview. Hosts/providers without verifiable
|
||||
Linux/systemd evidence refuse this legacy recovery; no Boolean fallback exists.
|
||||
|
||||
## Bounded continuation pilot
|
||||
|
||||
`torch continuation enable --max-turns-per-day 12 --yes` approves only the
|
||||
currently activated identity roster. `status` previews eligible identities;
|
||||
`tick --limit 3 --yes` runs at most three normal guarded `up` operations. New
|
||||
unacknowledged messages or assigned/in-progress task revision changes supply the
|
||||
signal; an unchanged signal is not retried. Active/unknown identities, held turn
|
||||
guards, detach, disabled policy and the daily UTC attempt cap prevent launch.
|
||||
Attempts are charged before invocation, including failed/interrupted attempts.
|
||||
The cap limits turns, **not dollars or token consumption**. Unknown quota remains
|
||||
unknown. This controller makes no product/approval/assignment decisions.
|
||||
|
||||
`torch continuation pause --yes` prevents subsequent launches. Policy and attempt
|
||||
ledger are separate atomic files so an in-flight turn cannot overwrite a pause.
|
||||
Pause does not kill an already-running turn. A controller lock serializes ticks;
|
||||
a crashed lock is retained rather than expired automatically. Each executor has
|
||||
a separate physical turn guard held through classification and final presence,
|
||||
so an agent reporting idle cannot permit overlapping native turns.
|
||||
|
||||
The first host pilot uses an explicitly installed transient user-systemd timer.
|
||||
It survives this chat ending, but not a host reboot. Permanent installer-owned
|
||||
provisioning/removal and installed artifact qualification remain existing backlog
|
||||
work; this is not a claim that the alpha.3 installed package has been upgraded.
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: new_tests
|
||||
scenarios:
|
||||
- id: SCN-legacy-runtime-recovery
|
||||
given: "An exact retained working/unknown identity and stopped project invocation"
|
||||
when: "The owner previews and applies recovery"
|
||||
then: "Presence becomes offline once; session, task and unknown outcome persist with one owner audit"
|
||||
- id: SCN-legacy-runtime-recovery-refusal
|
||||
given: "Live, foreign, mismatched or incomplete stopped-executor evidence"
|
||||
when: "Recovery evidence is requested"
|
||||
then: "Recovery refuses without identity mutation"
|
||||
- id: SCN-legacy-runtime-recovery-cas
|
||||
given: "Issued proof followed by a changed identity, live process, expiration or forgery"
|
||||
when: "Recovery is applied"
|
||||
then: "Transactional recovery refuses and creates no success audit"
|
||||
- id: SCN-legacy-runtime-recovery-authority
|
||||
given: "A specialist actor or incomplete expected snapshot"
|
||||
when: "Owner recovery is requested"
|
||||
then: "Authority or snapshot validation refuses"
|
||||
- id: SCN-runtime-turn-guard
|
||||
given: "A physical executor holds an identity guard and reports idle early"
|
||||
when: "Another executor attempts the same identity"
|
||||
then: "It refuses until the first executor finishes and releases its own guard"
|
||||
- id: SCN-runtime-continuation
|
||||
given: "An explicitly enabled roster, a new durable signal and a fixed daily turn cap"
|
||||
when: "Continuation ticks, repeats a signal, exhausts its cap or is paused"
|
||||
then: "Only eligible new signals launch; deduplication, cap, owner authority and pause remain enforced"
|
||||
observable_outcomes:
|
||||
- "Durable presence, session and task state; owner-attributed audit; explicit refusal codes"
|
||||
determinism_controls:
|
||||
- "Hermetic Git/install fixtures, fixed clock and injected OS observation boundary"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Caller Boolean as stopped proof"
|
||||
- "Process stop promoted to successful task or test"
|
||||
- "Stale snapshot overwrites or silent peer impersonation"
|
||||
- "Expired, foreign or replayed proof"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
# Live runtime presence instead of stale idle badges
|
||||
|
||||
The owner requires active executor turns to be distinguishable from old self-reported idle/offline state. Display is observational, not a new task-state machine or a substitute for authorization.
|
||||
|
||||
Each new physical turn guard records a starting phase and Linux process-start token, then enters working immediately before invoking the authorized executor. The read-only observation API validates the exact project/identity guard against a live same-user process with the matching start token. Existing legacy guards additionally require the exact TORCH up identity and canonical working directory. Dead, foreign-user, replaced or unobservable processes are not called active; retained unverified guards display unknown instead of false idle/offline.
|
||||
|
||||
An active verified guard overrides idle/offline display with starting/working. Original `reportedState`, `reportedSummary`, task and heartbeat are retained. Waiting/blocked reports remain visible even during a live turn. Guard removal naturally restores the last reported state after completion. No report, task, authority, launch policy, budget or receipt is changed by observation.
|
||||
|
||||
On unsupported OS process-observation surfaces, guarded execution remains unknown, not optimistically working. Other platform qualification remains open. Native harnesses with no resident TORCH executor may continue to rely on durable reports; this change does not claim to discover every external runtime process.
|
||||
|
||||
The live console pilot must load the source candidate to expose this observation. Updating its owned server does not upgrade the installed distribution or restart running agent turns. Broader startup/launch reservation work remains tracked by `TASK-pilot-starting-presence`.
|
||||
|
||||
Test integrity scenarios are recorded in docs/TEST_INTEGRITY.md. No existing expectations or test timeouts are relaxed.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Rolling continuation without a whole-batch barrier
|
||||
|
||||
Owner-requested fix for `TASK-d95e72d0-816d-4fc2-acaf-b73642fd4881`.
|
||||
|
||||
The serialized controller reserves and charges each launch before dispatch. It waits for the first pending turn to complete, records its outcome, re-reads current policy/backlog/messages and immediately fills any available slot. A long manager turn no longer prevents other eligible identities from running. The controller lock still excludes competing automatic controllers; pending launches and all observed physical turn guards count toward automatic capacity. Owner-requested manual batches are separate authorization and are not silently included in the daily automatic attempt ledger.
|
||||
|
||||
The same approved roster, per-identity mutex, pause, detached state and daily cap remain authoritative. A pause prevents additional launches and never kills an already-started turn. Unknown or failed executor outcomes are not silently retried. Retained crashed guards/controller locks remain fail-closed and require supported recovery.
|
||||
|
||||
Successful turns may continue unchanged assigned/in-progress work if recorded dependencies are complete. After two consecutive successful turns leave the same task IDs/revisions unchanged, further unchanged-work retries are held and one durable request asks the Manager to resolve the blocker. New task revisions or fresh messages can justify another bounded turn. This is a conservative no-progress signal, not proof that no files changed or that a task is done; the Manager must inspect evidence. Waiting, blocked, unassigned, completed or unmet-dependency work is not automatically converted into implementation work. Existing one-assignment rules and self-claim policy are not changed.
|
||||
|
||||
Resume instructions now request eligible implementation/verification/coordination rather than just a status report. They do not grant additional ownership or approval authority. All output completion remains exact-evidence gated.
|
||||
|
||||
Status exposes occupied guard identities and no-progress holds. Installed release and reboot persistence remain unqualified. Existing native pilot service executes this source candidate on its next activation; the daily limit remains 12 automatic attempts, not a token-cost governor.
|
||||
|
||||
Eligible identities are ordered least-recently-dispatched within the current UTC day, with the Manager first only on ties. A repeatedly revised specialist task must not consume every freed slot while an eligible peer has never started.
|
||||
|
||||
## Visible operating stops
|
||||
|
||||
The read-only project observation includes the automatic-turn budget, remaining attempts, exact pause/cap stop, retained capacity guards and current no-progress holds. It never resets the ledger, changes authority or treats declared policy as verified timer installation. No-progress holds are displayed only while the same active task IDs/revisions remain; resolved or revised tasks do not retain stale alerts.
|
||||
|
||||
An exhausted automatic allowance appears as an owner policy decision in Overview and as an explicit Fleet status line. Manager-owned no-progress waits remain with the Manager, rather than being falsely routed to the owner. There is no fabricated approval button or silent allowance increase. Manual owner turns are not included in this ledger; token/cost governance remains unknown.
|
||||
|
||||
Deterministic acceptance scenarios and anti-cheat notes are in docs/TEST_INTEGRITY.md. Independent live qualification remains necessary; do not close broader event-driven or persistent-operations umbrellas based on this source fix alone.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Explicit unread inbox on startup and resume
|
||||
|
||||
Legacy `torch_read_messages` defaults to the oldest 100 historical messages.
|
||||
Observed native inboxes exceeded 100 unread messages during fleet supervision.
|
||||
Launch/resume now explicitly requests the bound identity's unread inbox with a
|
||||
limit of 1000 and explains chronological order, potential overflow and deliberate
|
||||
acknowledgements. This uses the existing tool without changing its API contract.
|
||||
|
||||
This mitigation does not implement pagination, newest-first history, coherent
|
||||
cross-query observation, or complete overflow detection. Those remain in the
|
||||
existing unread-first inbox task. A result of 1000 is explicitly uncertain,
|
||||
not a complete-inbox claim. No permissions follow from reading a message.
|
||||
|
||||
SCN-fleet-startup-inbox exercises the actual fresh/resume launch arguments and
|
||||
registered identity-bound inbox tool against messages beyond the default page.
|
||||
@@ -0,0 +1,172 @@
|
||||
# Fleet arbiter checkpoint — 2026-10-03, 05:58 UTC
|
||||
|
||||
## Outcome
|
||||
|
||||
The owner-approved blocker-clearing directive produced concrete component
|
||||
commits and exact QA contributor consent. No new verified fleet landing has
|
||||
been established in this checkpoint. Automatic usage reached the approved
|
||||
48-turn cap; no additional provider turns were started or budget increased.
|
||||
|
||||
## Decisions and evidence
|
||||
|
||||
- Work committed its read-only convergence component at
|
||||
`7e0734ae90314b6182046b1d813efd40b453743f`.
|
||||
- Release committed read-only CLI routing at
|
||||
`dd77b7015e5d34851725237d6f424babccf7f7d4`.
|
||||
- QA message `c3f7ac15-5d40-4f85-a27b-6d5837b141a9` approved the exact
|
||||
compact-card source/test contribution. Actual browser capability remains
|
||||
unavailable in those native sessions; this is not visual acceptance.
|
||||
- Arbiter directive `8865db38-a077-4935-a154-7b566d65aa44` authorized
|
||||
source-only isolated composition without managed-branch convergence or
|
||||
integration. Corresponding directives were persisted for all implementers.
|
||||
|
||||
## Concrete compatibility failure
|
||||
|
||||
A detached checkout at canonical
|
||||
`7e3d333c5467c15d72be950f1fac9c3cd5439aee` was created at
|
||||
`/tmp/torch-readonly-composition-EirOji/candidate`. Applying exactly Kernel
|
||||
`18ccb0890d93c8a01e2c2d96db775b1ec14fe973` with `cherry-pick -x` produced
|
||||
content conflicts in `src/control-plane/service.mjs`. Composition stopped;
|
||||
Work and Release components were not applied, and no composed test PASS is
|
||||
claimed. The conflicted detached checkout is retained as diagnostic evidence.
|
||||
Canonical and managed agent checkouts were not altered by this operation.
|
||||
|
||||
The conflicts expose future schema-v3/reservation prerequisites absent from
|
||||
canonical, which initializes schema version 2. The reader requires
|
||||
`runtime_launch_reservations`, and its branch-local refusal scenario treats
|
||||
version 2 as incompatible. Pulling the entire future branch would therefore
|
||||
broaden this narrow current-branch unblock into an architecture upgrade.
|
||||
|
||||
The existing Kernel reader tests passed 2/2 on the Kernel branch under hermetic
|
||||
fixtures (`/tmp/torch-reader-component-review-20261003.log`). This is source
|
||||
review evidence only: it does not establish canonical compatibility, composed
|
||||
CLI behavior, operational receipts, independent QA, or installed qualification.
|
||||
|
||||
## Next bounded action
|
||||
|
||||
Kernel should supply a current-canonical-compatible reader slice without
|
||||
future reservation APIs, schema migration or live writer initialization. QA
|
||||
must approve strict version-relative refusal scenarios, not weaken assertions.
|
||||
Immutable SQLite observation also needs a demonstrated consistent snapshot or
|
||||
fail-closed behavior under concurrent writers, not only a sidecar precheck.
|
||||
Correction request `34a90b11-a64d-4ed7-a7ae-cf2ba8ef46f2` and corresponding
|
||||
manager/peer requests preserve the exact evidence and composition boundary.
|
||||
|
||||
## Controller handoff
|
||||
|
||||
The old three-slot controller drained without termination. At 05:57:52 UTC,
|
||||
the replacement seven-slot worker ran, obtained the released controller lock,
|
||||
and returned `daily-turn-cap` with `launched: []`. This verifies a cap-preserving
|
||||
handoff, not seven concurrent native workers or reboot persistence. The
|
||||
replacement timer remains configured; the ledger was not reset.
|
||||
|
||||
## Remaining gates
|
||||
|
||||
Current-base compatible composition, strict composed CLI scenarios, exact
|
||||
source gates, independent QA, guarded serialized integration and next-work
|
||||
continuation remain open. Native execution cannot continue today without an
|
||||
explicit new budget decision; the existing policy resets its daily allowance
|
||||
at UTC midnight. No deployment, installed-runtime upgrade, live DDL, authority
|
||||
expansion or completion assertion follows from this checkpoint.
|
||||
|
||||
## Subsequent verification and delivery blockers
|
||||
|
||||
The sections above describe the earlier checkpoint, not the latest outcome.
|
||||
Canonical source subsequently reached `3bb9c55`; the installed accepted runtime
|
||||
was not upgraded by the following source reviews.
|
||||
|
||||
### Current-v2 reader and CLI
|
||||
|
||||
The corrected Kernel slice `c79f8d4`, Work convergence slice `7e0734a`, and
|
||||
Release CLI slice `dd77b70` applied cleanly to canonical `3bb9c55` in the
|
||||
detached review tree `/tmp/torch-current-reader-review-vFjsSi/candidate`.
|
||||
The resulting candidate was `62629b806b00b4ceb8701c754c65b22738614eb9`.
|
||||
Root's full suite passed 282 tests with four explicit skips. A focused command
|
||||
named nonexistent `cli.test.mjs`; its 11 passing tests therefore did not prove
|
||||
CLI coverage. The actual full-suite glob did include `cli-scenarios.test.mjs`.
|
||||
|
||||
Root then removed the reader's `BEGIN` and transaction-open flag in an isolated
|
||||
mutant. The original four reader scenarios still passed: a demonstrated
|
||||
coverage gap, not proof of a production-reader defect. QA granted an additive
|
||||
correlated-generation scenario, delivered as
|
||||
`85914d3ef8a310878cec8345d976e78b3128d47e` atop `62629b8`.
|
||||
Root independently verified five reader scenarios passing on that exact
|
||||
candidate. The same no-transaction mutant failed the new scenario because a
|
||||
single reader combined generation A and generation B, not because close failed.
|
||||
Evidence was delivered to QA and Manager in owner messages `23102515` and
|
||||
`fd088d9f`. The review-only mutant remains in
|
||||
`/tmp/torch-reader-oracle-independent-W4arvj/candidate`; do not promote it.
|
||||
|
||||
Work separately delivered `cdc3aa9b800f7f2c1c925e1d8ecd21d9948fd3d2`, a sibling
|
||||
of `85914d3` atop `62629b8`. Its additive CLI scenario invokes actual `brief`,
|
||||
`converge plan`, and `converge guards` against a sidecar-free, write-refusing
|
||||
fixture and checks unchanged database bytes. Root verified that exact targeted
|
||||
scenario passing in `/tmp/torch-cli-reader-independent-YpA8xa/candidate` after
|
||||
providing dependencies. An initial invocation used the wrong working directory
|
||||
and failed importing `zod` before assertions; that setup failure is not a
|
||||
behavioral test result. No composed `85914d3` plus `cdc3aa9` candidate, native
|
||||
check receipt, installed qualification, or integration is asserted here.
|
||||
|
||||
### Compact-card source corrections
|
||||
|
||||
Candidate `29afa15` initially left long title/domain metadata unbounded: an
|
||||
isolated headless reproduction grew a card from 407px to 2,803px. Its current
|
||||
controls link also left the modal open over the destination. Correction
|
||||
`063034274a9e42b589aed6ef6071780b06f9a3b7` bounds those fields and closes/routes
|
||||
the modal. Root independently ran all six focused browser scenarios successfully.
|
||||
QA reported full-suite/check/dashboard/lint success for that source candidate.
|
||||
This is hermetic demo evidence, not connected live desktop/mobile qualification,
|
||||
uniform-height acceptance across every card type, or installed delivery.
|
||||
|
||||
The correction contains `Closes: TASK-console-compact-details`; the full task
|
||||
remains incomplete. Manager reported that automatic closure refuses a blocked
|
||||
task that is not ready to integrate. Preserve that boundary rather than making
|
||||
the task integration-ready solely to land a source slice. Existing managed
|
||||
Console branch `2261e9b` and its history remain separate from the detached source.
|
||||
|
||||
### Failed gates and operational requests
|
||||
|
||||
Runtime's converged `0bb5ca4a084ad19a99cef1d8ea3162bf29272f8f` and Kernel's
|
||||
`85914d3` both reported full-suite cancellation at the existing 60-second
|
||||
`cli-scenarios.test.mjs` boundary. Neither is a green full-suite result.
|
||||
Runtime preserved its excerpt and audit in its worktree under
|
||||
`test_logs/cli-suite-timeout-0bb5ca4a.observed-output.log` and
|
||||
`reports/test-audit/cli-suite-timeout-report-2026-10-03.md`.
|
||||
There is no TORCH check receipt for that direct `npm run test` invocation.
|
||||
The captured file-level output does not identify an internal CLI subscenario.
|
||||
No timeout increase, assertion relaxation, or retry-until-green was authorized.
|
||||
|
||||
Root submitted these concrete findings through durable owner requests:
|
||||
|
||||
- `700f9ac7`: live snapshot response took 9.182 seconds; the following static
|
||||
page response took 0.003552 seconds. Synchronous doctor/Git observation is a
|
||||
candidate cause, not a proven root cause. Profile without hiding stale state.
|
||||
- `db2380f0`: configuration defines a one-slot browser resource, but full-test
|
||||
and dashboard-check resource lists are empty. Direct test commands have no
|
||||
queue receipt. This coordination gap is verified; its role in the timeout
|
||||
is not. Avoid new competing full/browser suites while diagnosing it.
|
||||
- `0d25bf3e`: ordinary integration intake requires a registered specialist
|
||||
branch tip equal to the candidate SHA. Detached `626`/`859`/`cdc` do not meet
|
||||
that boundary, and Session Manager is not a feature-source identity. A
|
||||
reviewed lossless adoption path is needed; do not impersonate a specialist,
|
||||
reset its branch, import its whole future branch, or fabricate receipts.
|
||||
- `ea12134f`: Owner Console had both live parent and native Codex child for
|
||||
approximately 42 minutes while its durable progress report remained old.
|
||||
Separate executor liveness from recent activity and actual waiting reasons.
|
||||
Age alone does not authorize termination, guard cleanup, or restart.
|
||||
|
||||
### Launch allowance and next actions
|
||||
|
||||
An audited one-day allowance permitted 96 automatic launches on UTC
|
||||
2026-10-03, while the ordinary future daily cap remains 48. The ledger reached
|
||||
96 and the live observation returned `daily-turn-cap`. In-flight turns were
|
||||
allowed to finish; no ledger reset or further allowance was granted. The policy
|
||||
uses UTC midnight, not local midnight. It is an invocation allowance, not proof
|
||||
of token/cost governance or reboot-persistent scheduling.
|
||||
|
||||
Next work remains: independent current-candidate QA; deterministic diagnosis
|
||||
of the failed CLI gate and correct resource admission; lossless candidate
|
||||
adoption/intake; exact-SHA qualifying receipts; serialized integration;
|
||||
installed sandbox/startup verification; connected live UI review; and the
|
||||
remaining roadmap. Durable manager backlog and owner messages remain the
|
||||
authoritative queue. This report does not close those tasks or broaden authority.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Owner repair and bounded continuation pilot
|
||||
|
||||
## Candidate and verification
|
||||
|
||||
Canonical development branch: `rewrite/portable-agent-fleet`.
|
||||
|
||||
- `11dabd2`: exact stopped-unknown owner recovery, physical turn guard, opt-in continuation.
|
||||
- `4c42715`: reviewed Runtime MCP XDG runtime-directory forwarding, cherry-picked without its unfinished broader branch.
|
||||
- `f823714`: bounded parallel turns and signal watermarks that do not wake on acknowledgements alone.
|
||||
- Complete suite: 266 PASS, 4 skipped, zero failures/cancellations (`/tmp/torch-continuation-final-suite.log`). Syntax check and full lint PASS. Subsequent MCP focused suite: 6 PASS after removing unnecessary string escapes, with identical assertion semantics.
|
||||
|
||||
These are local/source verification results, not independent QA acceptance or an installed-release upgrade. No acceptance assertions or test timeouts were relaxed.
|
||||
|
||||
## Actual recovery
|
||||
|
||||
Owner-selected legacy units were verified terminal, with no live matching native process. The new public source CLI recovered these identities to offline, retaining prior unknown outcomes and runtime/session/task data:
|
||||
|
||||
| Identity | Owner audit |
|
||||
| --- | --- |
|
||||
| owner-console | cfae5b3d-695b-4657-95f0-cdba230741db |
|
||||
| qa | 158bc692-e775-49a2-987f-8d9d3e8ffb9a |
|
||||
| release-self-host | 7f618cd6-86cd-471f-a5f0-d7f03f8c6fb4 |
|
||||
|
||||
This is an explicit owner-selected legacy invocation binding, not cryptographic session provenance. Old failed units remain evidence. No processes were killed, task/test results forged, reservations fabricated, or schema migrations applied.
|
||||
|
||||
## Actual pilot
|
||||
|
||||
Project: `3dc11932-6e3c-42aa-9a8a-ca7b8f818b54`.
|
||||
|
||||
Transient user timer/service: `torch-3dc11932-6e3c-42aa-9a8a-ca7b8f818b54-continuation-pilot`.
|
||||
|
||||
First activation: 2026-10-03 04:00:18 UTC. Source CLI starts at most three existing approved identities concurrently, charges attempts before launch, and checks again two minutes after the previous batch exits. At verification, three attempts were charged and physical guards existed for session-manager, project-kernel and provider-runtime; their source `up` child processes were running. Daily cap: 12 turns, UTC day. This is a turn cap, NOT measured token/cost governance. No additional identities were authorized.
|
||||
|
||||
Monitoring URL returned HTTP 200: <http://127.0.0.1:4174/console>.
|
||||
|
||||
Pause future turns from the canonical repository:
|
||||
|
||||
```sh
|
||||
node bin/torch.mjs continuation pause --yes --json
|
||||
```
|
||||
|
||||
Pause does not kill already-started turns. Inspect policy/eligibility with `node bin/torch.mjs continuation status --json`. The timer remains a cheap deterministic check when paused or capped.
|
||||
|
||||
## Remaining qualification
|
||||
|
||||
- Independent QA of this exact composed candidate and real native completion/continuation.
|
||||
- Startup presence truthfulness: physical launches may still display idle until their first status report.
|
||||
- Installer-owned persistent timer registration, removal, upgrade and reboot qualification. This transient timer survives chat exit, not host reboot.
|
||||
- Crash reconciliation for retained controller/turn locks; no time-based expiry silently permits overlapping executors.
|
||||
- Real usage governor, runtime/model/auth qualification and operational failure reporting.
|
||||
- Existing umbrella tasks remain open; the previously observed CLI timeout still needs a root-cause diagnosis despite this passing run.
|
||||
|
||||
Installed alpha.3 runtime and console service were not replaced. The older schedule framework's automatic model wakes remain disabled; the source continuation pilot is separately authorized.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Experimental self-host activation — alpha.4.1
|
||||
|
||||
## Activated
|
||||
|
||||
The owner approved two bounded independent QA turns. Final QA verdict
|
||||
`f0db9c8e-c2c7-4d60-a4c6-a23567643907` accepted exact source
|
||||
`b0cab8eca1bc62ee908875dfac441a1b8a655fa0` for experimental active-pointer
|
||||
activation only. Installed version is `0.1.0-alpha.4.1`; alpha.3 remains available
|
||||
for rollback. No provider/model settings or daily usage limit were changed.
|
||||
|
||||
Real package acceptance: 293 PASS, zero failures/cancellations, four explicit
|
||||
skips (297 total); syntax and lint passed. Acceptance digest:
|
||||
`a522c0ac7058cf20187f13a7e9be694f90241e4e5fedff0eea6c8beeb84dd362`.
|
||||
Receipt lives in the installed version's `.torch-validation.json`.
|
||||
|
||||
Isolated installed smoke verified first-use installation, read-only brief and
|
||||
convergence readers, startup dry-run, alpha.3 rollback, re-upgrade and identical
|
||||
project configuration, without launching any provider.
|
||||
Evidence: `/tmp/torch-alpha41-installed-smoke-result.json`.
|
||||
|
||||
The existing console and continuation service ExecStart overrides now follow
|
||||
the validated active pointer. Only the owned console was restarted; the timer's
|
||||
cadence and budget are unchanged. Live loopback snapshot retrieval succeeded at
|
||||
`http://127.0.0.1:4174/console`. Installed manager and QA briefs succeeded from
|
||||
the host. The temporary qualification console on port 4175 was stopped.
|
||||
|
||||
## Boundaries and remaining work
|
||||
|
||||
- Canonical source remains `93602042eb561d7882549ffe298def4c29e4d1cf` on
|
||||
`rewrite/portable-agent-fleet`. Functional changes are committed on
|
||||
`release/selfhost-alpha4-20261003`, not canonically integrated or published.
|
||||
- QA did not approve canonical landing, deployment, publication or fleet resume.
|
||||
Registered-source adoption and native exact-commit integration gates remain.
|
||||
- Browser resource admission changes in the release configuration are not yet
|
||||
applied to the canonical project's tracked configuration.
|
||||
- The historical CLI timeout's cause is not established. Bounded test-process
|
||||
concurrency is mitigation, not retrospective qualification of failed checks.
|
||||
- Collapsed warning cards now retain full accessible modal evidence; uniform
|
||||
coverage of every card type and connected live browser review remain open.
|
||||
- QA's earlier native sandbox brief access failure still needs a native-runtime
|
||||
follow-up; successful host readers do not prove sandbox access is corrected.
|
||||
- The ordinary fleet allowance is unchanged; the current one-day allowance was
|
||||
exhausted. No additional specialist turn was authorized or launched.
|
||||
|
||||
This record is not a broader release-readiness claim.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Owner-authorized unlimited fleet resume
|
||||
|
||||
The owner explicitly requested removing the daily limit and continuing against
|
||||
subscription access. Installed alpha.4.2 source is committed at
|
||||
`df8e557685a5dbf4a27f5f03098ecb4495615527` on
|
||||
`release/selfhost-alpha4-20261003`. Functional source is not canonically integrated.
|
||||
|
||||
Real installed-candidate acceptance: 294 PASS, zero failures/cancellations,
|
||||
four explicit skips. Accepted digest:
|
||||
`8ffed308d89a696ac3b62ce6568fe23a0920c7221e0b651f2a0cec7de1231477`.
|
||||
The unlimited scenario first failed against capped code, then passed; an added
|
||||
physical-guard check initially used the wrong API signature and was corrected
|
||||
without dropping assertions. Failed intermediate acceptance was not activated.
|
||||
|
||||
Installed isolated smoke passed fresh install, unlimited CLI, read-only brief,
|
||||
pause preserving policy, restoring a finite cap before rollback, and unchanged
|
||||
project configuration. Zero providers were launched by smoke tests.
|
||||
Evidence: `/tmp/torch-alpha42-installed-smoke-result.json`.
|
||||
|
||||
## Live state
|
||||
|
||||
Activated alpha.4.2 through the accepted-version service. Set owner-audited
|
||||
`maxTurnsPerDay: null`, cleared the obsolete same-day override and retained all
|
||||
charged attempts. Concurrency remains seven. Sent durable resume/priority
|
||||
instructions to all seven standing identities, including an independent QA
|
||||
request for this incremental change; that request is not a completed review.
|
||||
|
||||
Started the existing continuation service and refreshed only the owned console.
|
||||
At 2026-10-03T20:11:01Z the installed live snapshot verified all seven identities
|
||||
with active process-backed working guards: Session Manager, Project Kernel,
|
||||
Provider Runtime, Work and Integration, Owner Console, Release and Self-host,
|
||||
Independent QA. Attempt count was 103, with no daily stop reason.
|
||||
Snapshot: `/tmp/torch-alpha42-live-snapshot.json`.
|
||||
|
||||
Dashboard remains `http://127.0.0.1:4174/console`. Its continuation label now says
|
||||
the daily limit is disabled instead of displaying a null/fake allowance.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Unlimited mode persists until the owner changes it; it is not a credit meter.
|
||||
No paid API fallback, model/profile change or bypass of approvals, ownership,
|
||||
verification, physical executor guards or stalled-work holds was enabled.
|
||||
Provider quota/auth failures remain failures, not permission to route elsewhere.
|
||||
The manager was instructed to avoid retry storms and prioritize unblockers.
|
||||
|
||||
Before rolling back to alpha.4.1 or older, restore a finite cap with the current
|
||||
runtime; those versions correctly reject the new null policy. This prevents an
|
||||
old runtime from silently interpreting unlimited as an ordinary allowance.
|
||||
Canonical integration, native sandbox brief follow-up and broader release
|
||||
qualification remain pending. Active execution alone is not proof of landed work.
|
||||
@@ -16,6 +16,18 @@ function attentionGroups(snapshot) {
|
||||
const agentName = (id) => agents.get(id)?.title ?? id ?? 'Owner not recorded';
|
||||
const add = (group, item) => groups[group].push(item);
|
||||
const approvals = (snapshot.approvalRequests?.items ?? []).filter((approval) => approval.status === 'pending');
|
||||
const continuation = snapshot.continuation;
|
||||
if (continuation?.available && continuation.stopReason === 'daily-turn-cap') add('owner', {
|
||||
tone: 'decision', title: 'Automatic fleet work stopped at its daily limit', owner: 'Project owner',
|
||||
detail: 'No additional automatic agent turns can start today unless you explicitly change the limit. Existing turns may finish; idle does not mean all work is done.',
|
||||
evidence: `${continuation.attempts}/${continuation.maxTurnsPerDay} automatic attempts · UTC day ${continuation.day} · not measured token/cost usage`,
|
||||
href: '#fleet', action: 'Review continuation policy', decisionApprovalId: null,
|
||||
});
|
||||
for (const hold of continuation?.held ?? []) add('fleet', {
|
||||
tone: 'review', title: `${agentName(hold.areaId)} needs a concrete coordination decision`, owner: agentName('session-manager'),
|
||||
detail: 'Two completed turns left the assigned task state unchanged. Unchanged-work retries are held; inspect evidence and resolve the actual blocker rather than restarting blindly.',
|
||||
evidence: hold.areaId, href: '#communications', action: 'Open coordination messages', requestOwner: 'session-manager',
|
||||
});
|
||||
|
||||
const advisory = [];
|
||||
const recheckCodes = new Set(['BACKLOG_OBSERVED_COMMIT_STALE', 'BACKLOG_OBSERVED_COMMIT_MISSING']);
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
|
||||
<section id="fleet" class="console-section fleet-section" data-console-view="fleet" aria-labelledby="fleet-title">
|
||||
<div class="section-heading"><div><p class="section-kicker">Persistent identities</p><h2 id="fleet-title">Fleet and ownership</h2></div><span id="agent-health-note">Session state from latest observation</span></div>
|
||||
<p id="continuation-status" class="surface-note" role="status">Reading automatic continuation policy…</p>
|
||||
<div class="fleet-columns">
|
||||
<article class="work-surface"><div class="panel-heading"><h3>Sessions</h3><span>One context per identity</span></div><div id="agent-list" class="agent-list"></div>
|
||||
<details class="runtime-profile-tools">
|
||||
|
||||
+7
-1
@@ -1039,6 +1039,12 @@ function renderAttention(groups) {
|
||||
}
|
||||
|
||||
function render(snapshot) {
|
||||
const continuation = snapshot.continuation;
|
||||
setText('#continuation-status', continuation?.available
|
||||
? `Automatic turns: ${continuation.attempts}/${continuation.maxTurnsPerDay} used today (UTC); ${continuation.remainingTurns} left. ${continuation.dailyOverride?.day === continuation.day ? `Owner-approved extra allowance expires at UTC midnight; normal daily cap ${continuation.configuredMaxTurnsPerDay}. ` : ''}Concurrency policy: up to ${continuation.maxConcurrency ?? 3}. ${continuation.stopReason === 'daily-turn-cap'
|
||||
? 'No further automatic starts: the owner-controlled daily cap is reached. Running turns may finish.'
|
||||
: continuation.stopReason === 'paused' ? 'Owner policy is paused; no new automatic starts.' : 'Policy permits new eligible turns; this does not verify timer installation.'} ${continuation.held?.length ?? 0} unchanged-work hold(s) need Manager coordination. This is not measured token or cost usage.`
|
||||
: 'Automatic continuation policy is unavailable; do not assume the fleet can start more work.');
|
||||
const project = snapshot.project ?? snapshot.repository;
|
||||
const agents = snapshot.agents ?? [];
|
||||
const tasks = snapshot.backlog ?? [];
|
||||
@@ -1087,7 +1093,7 @@ function render(snapshot) {
|
||||
return `<article class="agent-row">
|
||||
<span class="presence ${escapeHtml(agent.state)}" aria-label="${live ? 'Active' : 'Offline'}"></span>
|
||||
<div class="agent-copy"><strong>${escapeHtml(agent.title ?? agent.areaId)}</strong><span>${escapeHtml(agent.summary ?? profile)}</span><small>${escapeHtml(agent.areaId)} · ${escapeHtml(profile)}</small></div>
|
||||
<div class="agent-state"><strong>${escapeHtml(agent.state ?? 'unknown')}</strong><small>${escapeHtml(ageLabel(agent.heartbeatAt))}</small></div>
|
||||
<div class="agent-state"><strong>${escapeHtml(agent.state ?? 'unknown')}</strong><small>${escapeHtml(agent.executor?.state === 'active' ? 'executor verified live' : ageLabel(agent.heartbeatAt))}</small></div>
|
||||
</article>`;
|
||||
}).join('') : empty('Install an approved Fleet to see persistent identities.'));
|
||||
|
||||
|
||||
+18
-3
@@ -31,15 +31,28 @@ function commandRecord(command, args, cwd) {
|
||||
return { command, args, cwd, mutatesRuntime: true };
|
||||
}
|
||||
|
||||
function mcpArgs(mcp) {
|
||||
function mcpRuntimeEnvironmentNames(env, platform) {
|
||||
const runtimeDirectory = env?.XDG_RUNTIME_DIR;
|
||||
if (platform !== 'linux' || typeof runtimeDirectory !== 'string'
|
||||
|| !/^\/run\/user\/[1-9]\d*$/.test(runtimeDirectory)) {
|
||||
return [];
|
||||
}
|
||||
return ['XDG_RUNTIME_DIR'];
|
||||
}
|
||||
|
||||
function mcpArgs(mcp, { env, platform } = {}) {
|
||||
if (!mcp) return [];
|
||||
if (typeof mcp.name !== 'string' || !/^[a-z0-9_-]+$/.test(mcp.name)
|
||||
|| typeof mcp.command !== 'string' || !Array.isArray(mcp.args)) {
|
||||
throw new TorchError('Codex MCP launch configuration is invalid', { code: 'INVALID_RUNTIME_INPUT' });
|
||||
}
|
||||
const environmentNames = mcpRuntimeEnvironmentNames(env, platform);
|
||||
return [
|
||||
'-c', `mcp_servers.${mcp.name}.command=${JSON.stringify(mcp.command)}`,
|
||||
'-c', `mcp_servers.${mcp.name}.args=${JSON.stringify(mcp.args)}`,
|
||||
...(environmentNames.length
|
||||
? ['-c', `mcp_servers.${mcp.name}.env_vars=${JSON.stringify(environmentNames)}`]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -169,12 +182,14 @@ export function diagnoseCodexStartupFailure({ stdout, stderr } = {}) {
|
||||
export class CodexRuntimeAdapter {
|
||||
constructor({
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
runner = null,
|
||||
executable = 'codex',
|
||||
nodeExecutable = process.execPath,
|
||||
} = {}) {
|
||||
this.name = 'codex';
|
||||
this.env = env;
|
||||
this.platform = platform;
|
||||
this.runner = runner;
|
||||
this.executable = executable;
|
||||
this.nodeExecutable = nodeExecutable;
|
||||
@@ -241,7 +256,7 @@ export class CodexRuntimeAdapter {
|
||||
const cwd = text(worktree, 'worktree');
|
||||
const args = [
|
||||
'--cd', cwd, ...launchPolicyArgs(launchPolicy ?? this.configuration.launchPolicy),
|
||||
...mcpArgs(mcp),
|
||||
...mcpArgs(mcp, { env: this.env, platform: this.platform }),
|
||||
...((reasoning ?? this.configuration.reasoning) ? ['-c', `model_reasoning_effort=${JSON.stringify(text(reasoning ?? this.configuration.reasoning, 'reasoning'))}`] : []),
|
||||
'exec', '--json',
|
||||
...optionalModelArgs(model ?? this.configuration.model),
|
||||
@@ -266,7 +281,7 @@ export class CodexRuntimeAdapter {
|
||||
: '';
|
||||
const args = [
|
||||
'--cd', cwd, ...launchPolicyArgs(launchPolicy ?? this.configuration.launchPolicy),
|
||||
...mcpArgs(mcp),
|
||||
...mcpArgs(mcp, { env: this.env, platform: this.platform }),
|
||||
...((reasoning ?? this.configuration.reasoning) ? ['-c', `model_reasoning_effort=${JSON.stringify(text(reasoning ?? this.configuration.reasoning, 'reasoning'))}`] : []),
|
||||
'exec', 'resume', '--json',
|
||||
...optionalModelArgs(model ?? this.configuration.model), sessionId, `${text(message, 'message')}${currentInstructions}`,
|
||||
|
||||
+55
-1
@@ -14,7 +14,7 @@ import { startConsole } from './console/server.mjs';
|
||||
import { classifyRecoverability, createLocalCanonical, planLocalCanonical } from './canonical/local.mjs';
|
||||
import { openControlPlane, openControlPlaneReadOnly } from './control-plane/service.mjs';
|
||||
import { fetchCanonicalObjects, planCanonicalFetch } from './canonical/fetch.mjs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { spawn as spawnProcess, spawnSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
mkdirSync, mkdtempSync, renameSync, rmSync, statSync, writeFileSync,
|
||||
@@ -70,6 +70,8 @@ import {
|
||||
} from './design/architect.mjs';
|
||||
import { FleetEvolutionService } from './evolution/service.mjs';
|
||||
import { HierarchyEvolutionService } from './evolution/hierarchy.mjs';
|
||||
import { issueStoppedExecutorEvidence } from './runtime/stopped-executor-evidence.mjs';
|
||||
import { configureContinuation, planContinuation, runContinuation } from './runtime/continuation.mjs';
|
||||
import { ConvergenceService } from './convergence/service.mjs';
|
||||
import {
|
||||
attachForge, detachForge, forgeStatus, planForgeAttach, planForgeDetach,
|
||||
@@ -95,6 +97,8 @@ Usage:
|
||||
torch install --restore [--dry-run] [--yes] [--json]
|
||||
torch setup [--parent <path>] [--dry-run] [--yes] [--json]
|
||||
torch up [--fresh] [--only <id,id>] [--dry-run] [--yes] [--json]
|
||||
torch recover-runtime --area <id> --unit <service> --invocation <id> [--expected-session <id> --expected-updated-at <time> --yes] [--json]
|
||||
torch continuation <status|enable|pause|tick> [--max-turns-per-day <1-48>] [--today-max-turns <1-96>] [--concurrency <1-7>] [--limit <1-7>] [--yes] [--json]
|
||||
torch down [--dry-run] [--yes] [--json]
|
||||
torch capture [--json]
|
||||
torch detach [--dry-run] [--yes] [--json]
|
||||
@@ -585,6 +589,56 @@ export async function runCli(argv = process.argv.slice(2), {
|
||||
process.stdout.write(HELP);
|
||||
return 0;
|
||||
}
|
||||
if (command === 'continuation') {
|
||||
const control = openControlPlane({ repositoryRoot: optionValue(argv, '--repo') ?? cwd, env });
|
||||
try {
|
||||
const operation = argv[1] ?? 'status';
|
||||
const backlog = new BacklogService({ repositoryRoot: control.repositoryRoot, controlPlane: control });
|
||||
if (operation === 'status' || !argv.includes('--yes') || argv.includes('--dry-run')) {
|
||||
print(planContinuation(control, backlog), { json }); return 0;
|
||||
}
|
||||
if (operation === 'enable' || operation === 'pause') {
|
||||
print(configureContinuation(control, { actorId: 'owner', enabled: operation === 'enable',
|
||||
maxTurnsPerDay: optionValue(argv, '--max-turns-per-day') === undefined ? undefined : Number(optionValue(argv, '--max-turns-per-day')),
|
||||
todayMaxTurns: optionValue(argv, '--today-max-turns') === undefined ? undefined : Number(optionValue(argv, '--today-max-turns')),
|
||||
maxConcurrency: optionValue(argv, '--concurrency') === undefined ? undefined : Number(optionValue(argv, '--concurrency')) }), { json }); return 0;
|
||||
}
|
||||
if (operation !== 'tick') throw new TorchError('Unknown continuation operation.', { code: 'UNKNOWN_COMMAND' });
|
||||
print(await runContinuation(control, backlog, { actorId: 'owner', limit: optionValue(argv, '--limit') === undefined ? undefined : Number(optionValue(argv, '--limit')),
|
||||
launch: areaId => new Promise(resolveTurn => {
|
||||
const child = spawnProcess(process.execPath, [fileURLToPath(new URL('../bin/torch.mjs', import.meta.url)),
|
||||
'up', '--only', areaId, '--yes', '--json'], { cwd: control.repositoryRoot, env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
child.stdout.resume(); child.stderr.resume();
|
||||
let failed = false;
|
||||
child.on('error', () => { failed = true; });
|
||||
child.on('close', (code, signal) => resolveTurn(failed || signal ? null : code));
|
||||
}) }), { json });
|
||||
return 0;
|
||||
} finally { control.close(); }
|
||||
}
|
||||
if (command === 'recover-runtime') {
|
||||
const control = openControlPlane({ repositoryRoot: optionValue(argv, '--repo') ?? cwd, env });
|
||||
try {
|
||||
const areaId = optionValue(argv, '--area');
|
||||
const issued = issueStoppedExecutorEvidence({ controlPlane: control, actorId: 'owner', areaId,
|
||||
unit: optionValue(argv, '--unit'), invocationId: optionValue(argv, '--invocation') });
|
||||
if (!argv.includes('--yes') || argv.includes('--dry-run')) {
|
||||
print({ ...issued.plan, requiresApproval: true }, { json });
|
||||
return 0;
|
||||
}
|
||||
const expected = issued.plan.expectedIdentity;
|
||||
if (optionValue(argv, '--expected-session') !== expected.runtimeSessionId
|
||||
|| optionValue(argv, '--expected-updated-at') !== expected.updatedAt) {
|
||||
throw new TorchError('Apply requires the exact session and updated timestamp from the recovery preview.', {
|
||||
code: 'RUNTIME_RECOVERY_SNAPSHOT_CONFLICT',
|
||||
});
|
||||
}
|
||||
print(control.recoverOwnerStoppedUnknownIdentity({ actorId: 'owner', areaId,
|
||||
expectedIdentity: expected, evidence: issued.evidence }), { json });
|
||||
return 0;
|
||||
} finally { control.close(); }
|
||||
}
|
||||
if (command === 'runtimes') {
|
||||
const operation = argv[1] ?? 'list';
|
||||
if (operation === 'update') {
|
||||
|
||||
+371
-11
@@ -5,8 +5,9 @@ import { DatabaseSync } from 'node:sqlite';
|
||||
import { TorchError } from '../kernel/errors.mjs';
|
||||
import { readInstallManifest } from '../kernel/install.mjs';
|
||||
import { projectStatePath } from '../kernel/paths.mjs';
|
||||
import { loadProjectConfig } from '../kernel/config.mjs';
|
||||
import { loadProjectConfig, validateProjectConfig } from '../kernel/config.mjs';
|
||||
import { organizationGraphFromConfig } from '../kernel/organization.mjs';
|
||||
import { consumeStoppedExecutorEvidence } from '../runtime/stopped-executor-evidence.mjs';
|
||||
|
||||
export const PRESENCE_STATES = Object.freeze([
|
||||
'starting', 'working', 'waiting', 'idle', 'stale', 'stopping', 'offline',
|
||||
@@ -14,9 +15,23 @@ export const PRESENCE_STATES = Object.freeze([
|
||||
|
||||
const MESSAGE_KINDS = new Set([
|
||||
'message', 'coordination-request', 'handoff-request', 'completion', 'blocker', 'manager-check-in', 'manager-stale-work-review',
|
||||
'approval-request', 'approval-decision',
|
||||
'approval-request', 'approval-decision', 'owner-reply',
|
||||
]);
|
||||
|
||||
const CONTROL_PLANE_V2_SCHEMA_VERSION = 2;
|
||||
|
||||
// This reader intentionally recognizes only the canonical v2 state shape. A
|
||||
// newer state must be rejected rather than interpreted with a partial guess.
|
||||
const CONTROL_PLANE_V2_COLUMNS = Object.freeze({
|
||||
identities: ['area_id', 'runtime', 'runtime_session_id', 'state', 'summary', 'current_task', 'heartbeat_at', 'updated_at'],
|
||||
messages: ['id', 'project_id', 'sender_id', 'recipient_id', 'kind', 'created_at', 'body', 'task_ref', 'path_ref', 'commit_ref', 'handoff_ref', 'delivered_at'],
|
||||
message_acks: ['message_id', 'area_id', 'acknowledged_at'],
|
||||
reports: ['id', 'project_id', 'area_id', 'kind', 'summary', 'task_ref', 'evidence', 'created_at'],
|
||||
handoffs: ['id', 'project_id', 'sender_id', 'recipient_id', 'path_ref', 'task_ref', 'reason', 'status', 'created_at'],
|
||||
audit_events: ['id', 'project_id', 'actor_id', 'operation', 'entity_type', 'entity_id', 'details', 'created_at'],
|
||||
approval_requests: ['id', 'project_id', 'requester_id', 'approver_id', 'task_ref', 'title', 'summary', 'evidence', 'status', 'revision', 'created_at', 'decided_at', 'decided_by', 'decision_note'],
|
||||
});
|
||||
|
||||
function readJson(path, code) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf8'));
|
||||
@@ -25,6 +40,40 @@ function readJson(path, code) {
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonBytes(bytes, path, code) {
|
||||
try {
|
||||
return JSON.parse(bytes.toString('utf8'));
|
||||
} catch (error) {
|
||||
throw new TorchError(`Cannot read TORCH state at ${path}`, { code, details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
function readOnlyUnavailable(message, details = null) {
|
||||
return new TorchError(message, { code: 'CONTROL_PLANE_READ_ONLY_UNAVAILABLE', details });
|
||||
}
|
||||
|
||||
function exactColumns(database, table) {
|
||||
return database.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name);
|
||||
}
|
||||
|
||||
function validateV2ReadOnlySchema(database) {
|
||||
const version = database.prepare('PRAGMA user_version').get()?.user_version;
|
||||
if (version !== CONTROL_PLANE_V2_SCHEMA_VERSION) {
|
||||
throw readOnlyUnavailable('The control-plane state is not the supported schema-v2 snapshot.', {
|
||||
expected: CONTROL_PLANE_V2_SCHEMA_VERSION, actual: version ?? null,
|
||||
});
|
||||
}
|
||||
for (const [table, expected] of Object.entries(CONTROL_PLANE_V2_COLUMNS)) {
|
||||
const present = database.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
||||
const actual = present ? exactColumns(database, table) : null;
|
||||
if (!actual || actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) {
|
||||
throw readOnlyUnavailable('The control-plane state does not match the required schema-v2 tables.', {
|
||||
table, expected, actual,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requiredText(value, name) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new TorchError(`${name} must be a non-empty string`, {
|
||||
@@ -39,6 +88,35 @@ function optionalText(value, name) {
|
||||
return requiredText(value, name);
|
||||
}
|
||||
|
||||
function messageObservationSelection(value) {
|
||||
if (value !== 'unread' && value !== 'history') {
|
||||
throw new TorchError('Message observation selection must be unread or history', {
|
||||
code: 'INVALID_MESSAGE_SELECTION', details: { selection: value, allowed: ['unread', 'history'] },
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function messageObservationLimit(value) {
|
||||
if (!Number.isInteger(value) || value < 1 || value > 1000) {
|
||||
throw new TorchError('Message observation limit must be an integer from 1 through 1000', {
|
||||
code: 'INVALID_MESSAGE_LIMIT', details: { limit: value, minimum: 1, maximum: 1000 },
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ownerConversationCursor(value) {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)
|
||||
|| Object.keys(value).length !== 2 || !Object.hasOwn(value, 'createdAt') || !Object.hasOwn(value, 'id')) {
|
||||
throw new TorchError('Owner conversation cursor must contain only createdAt and id', {
|
||||
code: 'INVALID_OWNER_CONVERSATION_CURSOR',
|
||||
});
|
||||
}
|
||||
return { createdAt: requiredText(value.createdAt, 'cursor.createdAt'), id: requiredText(value.id, 'cursor.id') };
|
||||
}
|
||||
|
||||
function normalizeReferences(references = {}) {
|
||||
return {
|
||||
task: optionalText(references.task, 'references.task'),
|
||||
@@ -112,6 +190,13 @@ function effectiveRoster(roster, config) {
|
||||
}
|
||||
|
||||
function rowToMessage(row) {
|
||||
const references = {
|
||||
task: row.task_ref ?? null,
|
||||
path: row.path_ref ?? null,
|
||||
commit: row.commit_ref ?? null,
|
||||
handoff: row.handoff_ref ?? null,
|
||||
};
|
||||
if (row.kind === 'owner-reply') references.replyTo = row.handoff_ref ?? null;
|
||||
return {
|
||||
id: row.id,
|
||||
projectId: row.project_id,
|
||||
@@ -122,12 +207,7 @@ function rowToMessage(row) {
|
||||
body: row.body,
|
||||
acknowledgedAt: row.acknowledged_at ?? null,
|
||||
deliveredAt: row.delivered_at ?? null,
|
||||
references: {
|
||||
task: row.task_ref ?? null,
|
||||
path: row.path_ref ?? null,
|
||||
commit: row.commit_ref ?? null,
|
||||
handoff: row.handoff_ref ?? null,
|
||||
},
|
||||
references,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -224,10 +304,15 @@ function initializeSchema(database) {
|
||||
}
|
||||
|
||||
export class ControlPlane {
|
||||
constructor({ repositoryRoot, env = process.env, clock = () => new Date(), idFactory = randomUUID } = {}) {
|
||||
constructor({ repositoryRoot, env = process.env, clock = () => new Date(), idFactory = randomUUID, readOnly = false } = {}) {
|
||||
this.repositoryRoot = resolve(repositoryRoot ?? process.cwd());
|
||||
this.clock = clock;
|
||||
this.idFactory = idFactory;
|
||||
this.readOnly = readOnly === true;
|
||||
if (this.readOnly) {
|
||||
this.initializeReadOnly(env);
|
||||
return;
|
||||
}
|
||||
this.manifest = readInstallManifest(this.repositoryRoot);
|
||||
this.config = loadProjectConfig(this.repositoryRoot);
|
||||
this.roster = readJson(join(this.repositoryRoot, '.torch', 'roster.yaml'), 'INVALID_TORCH_ROSTER');
|
||||
@@ -267,7 +352,104 @@ export class ControlPlane {
|
||||
for (const areaId of this.agents.keys()) insert.run(areaId, 'offline');
|
||||
}
|
||||
|
||||
initializeReadOnly(env) {
|
||||
const trackedRoot = join(this.repositoryRoot, '.torch');
|
||||
const manifestPath = join(trackedRoot, 'install-manifest.json');
|
||||
const configPath = join(trackedRoot, 'torch.yaml');
|
||||
const rosterPath = join(trackedRoot, 'roster.yaml');
|
||||
try {
|
||||
this.readOnlyInputs = [
|
||||
{ path: manifestPath, bytes: readFileSync(manifestPath) },
|
||||
{ path: configPath, bytes: readFileSync(configPath) },
|
||||
{ path: rosterPath, bytes: readFileSync(rosterPath) },
|
||||
];
|
||||
this.manifest = readJsonBytes(this.readOnlyInputs[0].bytes, manifestPath, 'INVALID_INSTALL_MANIFEST');
|
||||
this.config = validateProjectConfig(readJsonBytes(this.readOnlyInputs[1].bytes, configPath, 'CONFIG_INVALID'));
|
||||
this.roster = readJsonBytes(this.readOnlyInputs[2].bytes, rosterPath, 'INVALID_TORCH_ROSTER');
|
||||
} catch (error) {
|
||||
if (error instanceof TorchError) throw error;
|
||||
throw readOnlyUnavailable('The tracked control-plane inputs could not be read atomically.', { cause: error.message });
|
||||
}
|
||||
if (this.config.project?.id !== this.manifest.projectId) {
|
||||
throw new TorchError('Tracked configuration and installation manifest disagree on project identity', {
|
||||
code: 'PROJECT_ID_MISMATCH',
|
||||
});
|
||||
}
|
||||
const localState = (this.manifest.external ?? []).find((entry) => entry.type === 'local-state');
|
||||
if (!localState?.path || !existsSync(localState.path)) {
|
||||
throw new TorchError('TORCH local project state is unavailable', { code: 'LOCAL_STATE_MISSING' });
|
||||
}
|
||||
const expectedStateRoot = projectStatePath(this.manifest.projectId, env);
|
||||
if (resolve(localState.path) !== resolve(expectedStateRoot)) {
|
||||
throw new TorchError('Installation manifest local state path does not match this environment', {
|
||||
code: 'LOCAL_STATE_PATH_MISMATCH',
|
||||
details: { manifest: localState.path, expected: expectedStateRoot },
|
||||
});
|
||||
}
|
||||
const metadataPath = join(expectedStateRoot, 'project.json');
|
||||
try {
|
||||
this.readOnlyInputs.push({ path: metadataPath, bytes: readFileSync(metadataPath) });
|
||||
} catch (error) {
|
||||
throw readOnlyUnavailable('The local control-plane metadata could not be read.', { cause: error.message });
|
||||
}
|
||||
const metadata = readJsonBytes(this.readOnlyInputs[3].bytes, metadataPath, 'LOCAL_STATE_METADATA_INVALID');
|
||||
if (metadata.projectId !== this.manifest.projectId || resolve(metadata.root) !== this.repositoryRoot) {
|
||||
throw new TorchError('Local state metadata does not belong to this installation root', {
|
||||
code: 'LOCAL_STATE_METADATA_MISMATCH',
|
||||
});
|
||||
}
|
||||
this.stateRoot = expectedStateRoot;
|
||||
this.projectId = this.manifest.projectId;
|
||||
this.agents = new Map(effectiveRoster(this.roster, this.config).map((area) => [area.id, area]));
|
||||
if (!this.agents.has('session-manager')) {
|
||||
throw new TorchError('Roster does not contain the required session-manager identity', {
|
||||
code: 'INVALID_TORCH_ROSTER',
|
||||
});
|
||||
}
|
||||
try {
|
||||
this.database = new DatabaseSync(join(this.stateRoot, 'state.db'), { readOnly: true });
|
||||
// A normal read transaction preserves SQLite's locking and WAL semantics.
|
||||
this.database.exec('BEGIN');
|
||||
this.readTransactionOpen = true;
|
||||
validateV2ReadOnlySchema(this.database);
|
||||
for (const areaId of this.agents.keys()) {
|
||||
if (!this.database.prepare('SELECT area_id FROM identities WHERE area_id = ?').get(areaId)) {
|
||||
throw readOnlyUnavailable('The schema-v2 snapshot does not contain every registered identity.', { areaId });
|
||||
}
|
||||
}
|
||||
this.assertReadOnlyInputsStable();
|
||||
} catch (error) {
|
||||
try { if (this.readTransactionOpen) this.database.exec('ROLLBACK'); } catch { /* close only */ }
|
||||
try { this.database?.close(); } catch { /* close only */ }
|
||||
if (error instanceof TorchError) throw error;
|
||||
throw readOnlyUnavailable('A coherent read-only control-plane snapshot is unavailable.', { cause: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
assertReadOnlyInputsStable() {
|
||||
if (!this.readOnly) return;
|
||||
for (const input of this.readOnlyInputs) {
|
||||
let current;
|
||||
try { current = readFileSync(input.path); } catch (error) {
|
||||
throw readOnlyUnavailable('A control-plane input disappeared while it was being read.', { path: input.path, cause: error.message });
|
||||
}
|
||||
if (!current.equals(input.bytes)) {
|
||||
throw readOnlyUnavailable('A control-plane input changed while the read-only snapshot was open.', { path: input.path });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertWritable() {
|
||||
if (this.readOnly) {
|
||||
throw new TorchError('This control-plane handle is read-only.', { code: 'CONTROL_PLANE_READ_ONLY' });
|
||||
}
|
||||
}
|
||||
|
||||
refreshRoster() {
|
||||
if (this.readOnly) {
|
||||
this.assertReadOnlyInputsStable();
|
||||
return { schema: this.roster.schema, areaIds: [...this.agents.keys()] };
|
||||
}
|
||||
const roster = readJson(join(this.repositoryRoot, '.torch', 'roster.yaml'), 'INVALID_TORCH_ROSTER');
|
||||
const config = loadProjectConfig(this.repositoryRoot);
|
||||
const agents = new Map(effectiveRoster(roster, config).map((area) => [area.id, area]));
|
||||
@@ -284,11 +466,18 @@ export class ControlPlane {
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.readTransactionOpen) {
|
||||
try { this.database.exec('COMMIT'); } finally { this.readTransactionOpen = false; }
|
||||
}
|
||||
this.database.close();
|
||||
}
|
||||
|
||||
assertIdentity(areaId) {
|
||||
this.refreshRoster();
|
||||
return this.assertLoadedIdentity(areaId);
|
||||
}
|
||||
|
||||
assertLoadedIdentity(areaId) {
|
||||
const normalized = requiredText(areaId, 'areaId');
|
||||
if (!this.agents.has(normalized)) {
|
||||
throw new TorchError(`Unknown Fleet identity: ${normalized}`, {
|
||||
@@ -307,6 +496,9 @@ export class ControlPlane {
|
||||
identity(areaId) {
|
||||
const id = this.assertIdentity(areaId);
|
||||
const row = this.database.prepare('SELECT * FROM identities WHERE area_id = ?').get(id);
|
||||
if (this.readOnly && !row) {
|
||||
throw readOnlyUnavailable('The read-only snapshot is missing a registered identity.', { areaId: id });
|
||||
}
|
||||
return rowToAgent(row, this.agents.get(id));
|
||||
}
|
||||
|
||||
@@ -366,6 +558,7 @@ export class ControlPlane {
|
||||
}
|
||||
|
||||
sendMessage({ sender, recipient, body, kind = 'message', references = {} } = {}) {
|
||||
this.assertWritable();
|
||||
const senderId = this.assertIdentity(sender);
|
||||
const recipientId = this.assertRecipient(recipient);
|
||||
const normalizedKind = requiredText(kind, 'kind');
|
||||
@@ -395,6 +588,7 @@ export class ControlPlane {
|
||||
}
|
||||
|
||||
sendOwnerRequest({ actorId, recipient, body, references = {} } = {}) {
|
||||
this.assertWritable();
|
||||
const ownerId = this.assertOwnerActor(actorId);
|
||||
const recipientId = this.assertIdentity(recipient);
|
||||
const normalizedBody = requiredText(body, 'body');
|
||||
@@ -425,9 +619,43 @@ export class ControlPlane {
|
||||
return record;
|
||||
}
|
||||
|
||||
sendOwnerReply({ sender, requestId, body } = {}) {
|
||||
this.assertWritable();
|
||||
const senderId = this.assertIdentity(sender);
|
||||
const parentId = requiredText(requestId, 'requestId');
|
||||
const parent = this.database.prepare(`
|
||||
SELECT * FROM messages
|
||||
WHERE id = ? AND project_id = ? AND sender_id = 'owner' AND recipient_id = ? AND kind = 'message'
|
||||
`).get(parentId, this.projectId, senderId);
|
||||
if (!parent) {
|
||||
throw new TorchError('Owner reply must reference an owner request addressed to the sender', {
|
||||
code: 'OWNER_REPLY_REFERENCE_INVALID', details: { requestId: parentId, sender: senderId },
|
||||
});
|
||||
}
|
||||
const normalizedBody = requiredText(body, 'body');
|
||||
const record = {
|
||||
id: this.idFactory(), projectId: this.projectId, sender: senderId, recipient: 'owner',
|
||||
kind: 'owner-reply', createdAt: this.clock().toISOString(), body: normalizedBody,
|
||||
acknowledgedAt: null, deliveredAt: null,
|
||||
references: { task: null, path: null, commit: null, handoff: parentId, replyTo: parentId },
|
||||
};
|
||||
this.database.prepare(`
|
||||
INSERT INTO messages (
|
||||
id, project_id, sender_id, recipient_id, kind, created_at, body,
|
||||
task_ref, path_ref, commit_ref, handoff_ref
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
record.id, record.projectId, record.sender, record.recipient, record.kind,
|
||||
record.createdAt, record.body, null, null, null, parentId,
|
||||
);
|
||||
this.audit({ actorId: senderId, operation: 'message.reply-owner', entityType: 'message', entityId: record.id,
|
||||
details: { requestId: parentId } });
|
||||
return record;
|
||||
}
|
||||
|
||||
readMessages({ recipient, unacknowledgedOnly = false, limit = 100 } = {}) {
|
||||
const recipientId = this.assertIdentity(recipient);
|
||||
const normalizedLimit = Number.isInteger(limit) && limit > 0 && limit <= 1000 ? limit : 100;
|
||||
const normalizedLimit = messageObservationLimit(limit);
|
||||
const query = `
|
||||
SELECT m.*, a.acknowledged_at
|
||||
FROM messages m
|
||||
@@ -440,7 +668,96 @@ export class ControlPlane {
|
||||
return this.database.prepare(query).all(recipientId, recipientId, normalizedLimit).map(rowToMessage);
|
||||
}
|
||||
|
||||
observeMessages({ recipient, selection, limit } = {}) {
|
||||
// Unlike legacy readMessages, this observation must not refresh the roster:
|
||||
// refreshRoster maintains identity rows and is not a read-only observation.
|
||||
const recipientId = this.assertLoadedIdentity(recipient);
|
||||
const normalizedSelection = messageObservationSelection(selection);
|
||||
const normalizedLimit = messageObservationLimit(limit);
|
||||
const rows = this.database.prepare(`
|
||||
WITH visible_messages AS MATERIALIZED (
|
||||
SELECT m.*, a.acknowledged_at
|
||||
FROM messages m
|
||||
LEFT JOIN message_acks a ON a.message_id = m.id AND a.area_id = ?
|
||||
WHERE m.recipient_id = ? OR m.recipient_id = 'all'
|
||||
),
|
||||
selected_messages AS MATERIALIZED (
|
||||
SELECT * FROM visible_messages
|
||||
WHERE ? = 'history' OR acknowledged_at IS NULL
|
||||
),
|
||||
page AS MATERIALIZED (
|
||||
SELECT * FROM selected_messages
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ?
|
||||
),
|
||||
metadata AS (
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM selected_messages) AS selected_count,
|
||||
(SELECT COUNT(*) FROM visible_messages WHERE acknowledged_at IS NULL) AS pending_unread_count
|
||||
)
|
||||
SELECT page.*, metadata.selected_count, metadata.pending_unread_count
|
||||
FROM metadata
|
||||
LEFT JOIN page ON TRUE
|
||||
ORDER BY page.created_at ASC, page.id ASC
|
||||
`).all(recipientId, recipientId, normalizedSelection, normalizedLimit);
|
||||
const messages = rows.filter((row) => row.id !== null).map(rowToMessage);
|
||||
const selectedCount = Number(rows[0]?.selected_count ?? 0);
|
||||
const complete = selectedCount <= normalizedLimit;
|
||||
return {
|
||||
recipient: recipientId,
|
||||
messages,
|
||||
observation: {
|
||||
selection: normalizedSelection,
|
||||
requestedLimit: normalizedLimit,
|
||||
returnedCount: messages.length,
|
||||
complete,
|
||||
truncated: !complete,
|
||||
pendingUnreadCount: Number(rows[0]?.pending_unread_count ?? 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
readOwnerConversation({ actorId, requestId, cursor, limit = 100 } = {}) {
|
||||
this.assertOwnerActor(actorId);
|
||||
const parentId = requiredText(requestId, 'requestId');
|
||||
const pageCursor = ownerConversationCursor(cursor);
|
||||
const normalizedLimit = messageObservationLimit(limit);
|
||||
const request = this.database.prepare(`
|
||||
SELECT * FROM messages
|
||||
WHERE id = ? AND project_id = ? AND sender_id = 'owner' AND kind = 'message'
|
||||
`).get(parentId, this.projectId);
|
||||
if (!request) {
|
||||
throw new TorchError('Owner conversation request is unavailable in this project', {
|
||||
code: 'OWNER_CONVERSATION_NOT_FOUND', details: { requestId: parentId },
|
||||
});
|
||||
}
|
||||
const rows = this.database.prepare(`
|
||||
SELECT * FROM messages
|
||||
WHERE project_id = ? AND recipient_id = 'owner' AND kind = 'owner-reply' AND handoff_ref = ?
|
||||
AND (? IS NULL OR created_at > ? OR (created_at = ? AND id > ?))
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ?
|
||||
`).all(
|
||||
this.projectId, parentId,
|
||||
pageCursor?.createdAt ?? null, pageCursor?.createdAt ?? null,
|
||||
pageCursor?.createdAt ?? null, pageCursor?.id ?? null,
|
||||
normalizedLimit + 1,
|
||||
);
|
||||
const hasMore = rows.length > normalizedLimit;
|
||||
const replies = rows.slice(0, normalizedLimit).map(rowToMessage);
|
||||
const last = replies.at(-1);
|
||||
return {
|
||||
request: rowToMessage(request), replies,
|
||||
page: {
|
||||
requestedLimit: normalizedLimit, returnedCount: replies.length,
|
||||
complete: !hasMore, truncated: hasMore,
|
||||
nextCursor: hasMore && last ? { createdAt: last.createdAt, id: last.id } : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
ackMessage({ recipient, messageId } = {}) {
|
||||
this.assertWritable();
|
||||
const recipientId = this.assertIdentity(recipient);
|
||||
const id = requiredText(messageId, 'messageId');
|
||||
const message = this.database.prepare('SELECT * FROM messages WHERE id = ?').get(id);
|
||||
@@ -459,7 +776,39 @@ export class ControlPlane {
|
||||
return { messageId: id, recipient: recipientId, acknowledgedAt };
|
||||
}
|
||||
|
||||
recoverOwnerStoppedUnknownIdentity({ actorId, areaId, expectedIdentity, evidence } = {}) {
|
||||
this.assertWritable();
|
||||
const owner = this.assertOwnerActor(actorId);
|
||||
const id = this.assertIdentity(areaId);
|
||||
if (!expectedIdentity || ['state', 'runtime', 'runtimeSessionId', 'updatedAt'].some(key => !expectedIdentity[key])) {
|
||||
throw new TorchError('Recovery requires the complete previewed identity snapshot.', { code: 'RUNTIME_RECOVERY_SNAPSHOT_REQUIRED' });
|
||||
}
|
||||
this.database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const current = this.identity(id);
|
||||
if (current.state !== 'working' || !current.summary?.includes('Runtime executor completion is unknown')
|
||||
|| ['state', 'runtime', 'runtimeSessionId', 'updatedAt'].some(key => current[key] !== expectedIdentity[key])) {
|
||||
throw new TorchError('Identity changed since recovery was reviewed.', { code: 'RUNTIME_RECOVERY_SNAPSHOT_CONFLICT' });
|
||||
}
|
||||
const facts = consumeStoppedExecutorEvidence(evidence, { projectId: this.projectId, repositoryRoot: this.repositoryRoot, identity: current });
|
||||
const now = this.clock().toISOString();
|
||||
const result = this.database.prepare(`UPDATE identities SET state = 'offline', summary = ?, heartbeat_at = ?, updated_at = ?
|
||||
WHERE area_id = ? AND state = ? AND runtime = ? AND runtime_session_id = ? AND updated_at = ?`)
|
||||
.run('Owner verified stopped legacy executor; prior outcome remains unknown.', now, now,
|
||||
id, current.state, current.runtime, current.runtimeSessionId, current.updatedAt);
|
||||
if (result.changes !== 1) throw new TorchError('Recovery lost its identity snapshot.', { code: 'RUNTIME_RECOVERY_SNAPSHOT_CONFLICT' });
|
||||
const audit = this.auditOwnerAction({ actorId: owner, operation: 'runtime.legacy-unknown.recover-stopped',
|
||||
entityType: 'identity', entityId: id, details: { previousIdentity: expectedIdentity, ...facts } });
|
||||
this.database.exec('COMMIT');
|
||||
return { identity: this.identity(id), auditId: audit.id, priorOutcome: 'unknown', sessionsStarted: false, mutationPerformed: true };
|
||||
} catch (error) {
|
||||
this.database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
reportStatus({ areaId, state, summary, runtime, runtimeSessionId, task } = {}) {
|
||||
this.assertWritable();
|
||||
const id = this.assertIdentity(areaId);
|
||||
const normalizedState = requiredText(state, 'state');
|
||||
if (!PRESENCE_STATES.includes(normalizedState)) {
|
||||
@@ -486,6 +835,7 @@ export class ControlPlane {
|
||||
}
|
||||
|
||||
writeReport({ areaId, kind, summary, task, evidence } = {}) {
|
||||
this.assertWritable();
|
||||
const id = this.assertIdentity(areaId);
|
||||
const createdAt = this.clock().toISOString();
|
||||
const report = {
|
||||
@@ -535,6 +885,7 @@ export class ControlPlane {
|
||||
}
|
||||
|
||||
requestHandoff({ sender, recipient = 'session-manager', path, task, reason } = {}) {
|
||||
this.assertWritable();
|
||||
const senderId = this.assertIdentity(sender);
|
||||
const recipientId = this.assertIdentity(recipient);
|
||||
const pathRef = path ? normalizedProjectPath(path) : null;
|
||||
@@ -576,6 +927,7 @@ export class ControlPlane {
|
||||
}
|
||||
|
||||
requestApproval({ requester, approver, task, title, summary, evidence } = {}) {
|
||||
this.assertWritable();
|
||||
const requesterId = this.assertIdentity(requester);
|
||||
const approverId = requiredText(approver, 'approver');
|
||||
if (approverId === 'owner') this.assertOwnerActor(approverId);
|
||||
@@ -651,6 +1003,7 @@ export class ControlPlane {
|
||||
}
|
||||
|
||||
decideApproval({ approvalId, decidedBy, decision, note, expectedRevision } = {}) {
|
||||
this.assertWritable();
|
||||
const id = requiredText(approvalId, 'approvalId');
|
||||
const actor = requiredText(decidedBy, 'decidedBy');
|
||||
const owner = actor === 'owner';
|
||||
@@ -708,6 +1061,7 @@ export class ControlPlane {
|
||||
}
|
||||
|
||||
insertOwnerApprovalDecisionMessage({ sender, recipient, approvalId, task, decision, note }) {
|
||||
this.assertWritable();
|
||||
const id = this.idFactory();
|
||||
const createdAt = this.clock().toISOString();
|
||||
const body = `Approval ${decision}: ${note ?? 'No additional note.'}`;
|
||||
@@ -732,6 +1086,7 @@ export class ControlPlane {
|
||||
}
|
||||
|
||||
audit({ actorId, operation, entityType, entityId = null, details = null } = {}) {
|
||||
this.assertWritable();
|
||||
const actor = this.assertIdentity(actorId);
|
||||
const event = {
|
||||
id: this.idFactory(), projectId: this.projectId, actorId: actor,
|
||||
@@ -750,8 +1105,9 @@ export class ControlPlane {
|
||||
}
|
||||
|
||||
auditOwnerAction({ actorId, operation, entityType, entityId = null, details = null } = {}) {
|
||||
this.assertWritable();
|
||||
const actor = this.assertOwnerActor(actorId);
|
||||
if (!['artifact.feedback', 'backlog.priority', 'backlog.create', 'agent.request', 'approval.decide', 'approval.notify', 'forge.sync', 'canonical.fetch', 'profile.change', 'schedule.launcher.install', 'schedule.launcher.reconcile', 'schedule.wake.recover', 'hierarchy.conclude-pilot', 'check.recover-prepared'].includes(operation)) {
|
||||
if (!['artifact.feedback', 'backlog.priority', 'backlog.create', 'agent.request', 'approval.decide', 'approval.notify', 'forge.sync', 'canonical.fetch', 'profile.change', 'schedule.launcher.install', 'schedule.launcher.reconcile', 'schedule.wake.recover', 'hierarchy.conclude-pilot', 'check.recover-prepared', 'runtime.legacy-unknown.recover-stopped', 'runtime.continuation.configure'].includes(operation)) {
|
||||
throw new TorchError('This owner audit surface does not authorize the requested operation', {
|
||||
code: 'OWNER_OPERATION_UNSUPPORTED', details: { operation },
|
||||
});
|
||||
@@ -803,3 +1159,7 @@ export class ControlPlane {
|
||||
export function openControlPlane(options) {
|
||||
return new ControlPlane(options);
|
||||
}
|
||||
|
||||
export function openControlPlaneReadOnly(options) {
|
||||
return new ControlPlane({ ...options, readOnly: true });
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { TorchError } from '../kernel/errors.mjs';
|
||||
import { observeRuntimeTurn } from '../runtime/turn-guard.mjs';
|
||||
|
||||
const ADOPTION_SCHEMA = 'torch.dev/integration-adoption/v1alpha1';
|
||||
const AUTHORITY_SCHEMA = 'torch.dev/integration-adoption-authority/v1alpha1';
|
||||
const ADAPTER_NAME = 'git-private-ref-cas-v1';
|
||||
const ACTIONS = new Set(['plan', 'prepare', 'finalize', 'reconcile', 'rollback', 'request']);
|
||||
const MUTATING_ACTIONS = new Set(['prepare', 'finalize', 'rollback', 'request']);
|
||||
const SHA = /^[0-9a-f]{40,64}$/;
|
||||
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}$/;
|
||||
const ZERO_SHA = '0'.repeat(40);
|
||||
|
||||
function git(root, args, { allowFailure = false } = {}) {
|
||||
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
||||
if (result.status !== 0 && !allowFailure) {
|
||||
throw new TorchError(`Git command failed: git ${args.join(' ')}`, {
|
||||
code: 'GIT_COMMAND_FAILED', details: result.stderr?.trim() || null,
|
||||
});
|
||||
}
|
||||
return { status: result.status, stdout: result.stdout?.trim() ?? '', stderr: result.stderr?.trim() ?? '' };
|
||||
}
|
||||
|
||||
function tableExists(database, name) {
|
||||
return Boolean(database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
||||
}
|
||||
|
||||
function text(value, name) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new TorchError(`${name} is required`, { code: 'ADOPTION_INPUT_INVALID', details: { name } });
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function sha(value, name) {
|
||||
const normalized = text(value, name);
|
||||
if (!SHA.test(normalized)) throw new TorchError(`${name} must be a full Git object id`, {
|
||||
code: 'ADOPTION_INPUT_INVALID', details: { name },
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function digest(value) {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
|
||||
function durableRecord(event) {
|
||||
if (!event?.details) return null;
|
||||
try {
|
||||
const record = JSON.parse(event.details);
|
||||
return record?.schema === ADOPTION_SCHEMA ? record : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function listWorktreePaths(root) {
|
||||
const records = [];
|
||||
for (const line of git(root, ['worktree', 'list', '--porcelain']).stdout.split('\n')) {
|
||||
if (line.startsWith('worktree ')) records.push(line.slice('worktree '.length));
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function authorityFrom(approval) {
|
||||
if (!approval || approval.status !== 'approved' || approval.approver_id !== 'owner' || approval.decided_by !== 'owner') return null;
|
||||
try {
|
||||
const authority = JSON.parse(approval.decision_note ?? '');
|
||||
return authority?.schema === AUTHORITY_SCHEMA ? authority : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function exactAuthority(authority, binding) {
|
||||
if (!authority || authority.action !== binding.action) return false;
|
||||
const fields = ['adoptionId', 'candidateSha', 'baseTargetSha', 'provenanceManifestDigest',
|
||||
'contributorArea', 'destinationArea', 'targetBranch', 'candidateRef', 'expectedOldRef'];
|
||||
return fields.every((field) => authority[field] === binding[field])
|
||||
&& authority.adapter?.name === binding.adapter?.name
|
||||
&& authority.adapter?.id === binding.adapter?.id;
|
||||
}
|
||||
|
||||
function exactInput(record, input) {
|
||||
const fields = ['adoptionId', 'candidateSha', 'baseTargetSha', 'provenanceManifestDigest',
|
||||
'contributorArea', 'destinationArea', 'targetBranch', 'candidateRef', 'expectedOldRef'];
|
||||
const mismatches = fields.filter((field) => input[field] !== undefined
|
||||
&& input[field] !== (field === 'adoptionId' ? record.id : record[field]));
|
||||
if (input.provenance !== undefined && JSON.stringify(input.provenance) !== JSON.stringify(record.provenance)) mismatches.push('provenance');
|
||||
if (mismatches.length) throw new TorchError('Lifecycle input cannot override immutable prepared adoption fields', {
|
||||
code: 'ADOPTION_IMMUTABLE_BINDING_STALE', details: { adoptionId: record.id, fields: mismatches },
|
||||
});
|
||||
if (!Number.isInteger(input.expectedRecordRevision) || input.expectedRecordRevision !== record.revision) {
|
||||
throw new TorchError('Lifecycle input must bind the current adoption record revision', {
|
||||
code: 'ADOPTION_RECORD_REVISION_STALE',
|
||||
details: { adoptionId: record.id, expectedRevision: input.expectedRecordRevision ?? null, actual: record.revision },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Real Git private-ref transaction adapter. It is source code only: callers
|
||||
* decide when it is invoked, while the adapter itself gives temp-repository
|
||||
* scenarios the same atomic update-ref semantics as a real repository.
|
||||
*/
|
||||
export class GitPrivateRefAdapter {
|
||||
constructor({ repositoryRoot } = {}) {
|
||||
this.repositoryRoot = resolve(text(repositoryRoot, 'repositoryRoot'));
|
||||
this.name = ADAPTER_NAME;
|
||||
this.id = digest({ adapter: this.name, repositoryRoot: this.repositoryRoot });
|
||||
}
|
||||
|
||||
readRefs(refs) {
|
||||
const values = {};
|
||||
for (const ref of refs) {
|
||||
const result = git(this.repositoryRoot, ['rev-parse', '--verify', '--quiet', ref], { allowFailure: true });
|
||||
values[ref] = result.status === 0 ? result.stdout : null;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
compareAndSwapRefs({ expected = [], writes = [] } = {}) {
|
||||
const commands = ['start'];
|
||||
const writtenRefs = new Set(writes.map((entry) => entry.ref));
|
||||
for (const { ref, sha: expectedSha } of expected) {
|
||||
if (writtenRefs.has(ref)) continue;
|
||||
commands.push(`verify ${text(ref, 'ref')} ${expectedSha ?? ZERO_SHA}`);
|
||||
}
|
||||
for (const { ref, sha: nextSha } of writes) {
|
||||
const expectedEntry = expected.find((entry) => entry.ref === ref);
|
||||
if (!expectedEntry) throw new TorchError('Every private-ref write requires an exact expected value', {
|
||||
code: 'ADOPTION_REF_CAS_EXPECTED_REQUIRED', details: { ref },
|
||||
});
|
||||
const current = expectedEntry.sha ?? null;
|
||||
if (nextSha === null) commands.push(`delete ${text(ref, 'ref')} ${current ?? ZERO_SHA}`);
|
||||
else commands.push(`update ${text(ref, 'ref')} ${sha(nextSha, 'next ref sha')} ${current ?? ZERO_SHA}`);
|
||||
}
|
||||
commands.push('prepare', 'commit', '');
|
||||
const result = spawnSync('git', ['-C', this.repositoryRoot, 'update-ref', '--stdin'], {
|
||||
input: commands.join('\n'), encoding: 'utf8',
|
||||
});
|
||||
if (result.status !== 0) throw new TorchError('Private ref compare-and-swap refused', {
|
||||
code: 'ADOPTION_REF_CAS_FAILED', details: result.stderr?.trim() || null,
|
||||
});
|
||||
return { adapter: { name: this.name, id: this.id }, expected, writes };
|
||||
}
|
||||
}
|
||||
|
||||
export class AdoptionService {
|
||||
constructor({ repositoryRoot, controlPlane, clock = () => new Date(), idFactory = randomUUID, policy, refAdapter } = {}) {
|
||||
this.repositoryRoot = repositoryRoot;
|
||||
this.controlPlane = controlPlane;
|
||||
this.clock = clock;
|
||||
this.idFactory = idFactory;
|
||||
this.policy = policy ?? {};
|
||||
this.refAdapter = refAdapter ?? new GitPrivateRefAdapter({ repositoryRoot });
|
||||
}
|
||||
|
||||
plan({ action = 'plan', actorId, approvalId, approvalRevision, adoptionId,
|
||||
candidateSha, baseTargetSha, provenance, provenanceManifestDigest, contributorArea,
|
||||
destinationArea, targetBranch, candidateRef, expectedOldRef, adapter } = {}) {
|
||||
const blockers = [];
|
||||
const id = adoptionId ? text(adoptionId, 'adoptionId') : null;
|
||||
if (id && !SAFE_ID.test(id)) blockers.push({ code: 'ADOPTION_ID_INVALID' });
|
||||
if (!ACTIONS.has(action)) blockers.push({ code: 'ADOPTION_ACTION_INVALID' });
|
||||
if (actorId !== 'owner') blockers.push({ code: 'OWNER_ACTOR_REQUIRED' });
|
||||
if (!Number.isInteger(approvalRevision) || approvalRevision < 1) blockers.push({ code: 'ADOPTION_APPROVAL_REVISION_REQUIRED' });
|
||||
const adapterBinding = adapter?.name && adapter?.id ? { name: adapter.name, id: adapter.id } : null;
|
||||
if (MUTATING_ACTIONS.has(action) && !adapterBinding) blockers.push({ code: 'ADOPTION_ADAPTER_BINDING_REQUIRED' });
|
||||
if (adapterBinding && (adapterBinding.name !== this.refAdapter.name || adapterBinding.id !== this.refAdapter.id)) {
|
||||
blockers.push({ code: 'ADOPTION_ADAPTER_PROVENANCE_INVALID' });
|
||||
}
|
||||
|
||||
let candidate; let base; let provenanceDigest;
|
||||
try {
|
||||
candidate = sha(candidateSha, 'candidateSha');
|
||||
base = sha(baseTargetSha, 'baseTargetSha');
|
||||
if (!Array.isArray(provenance) || !provenance.length || provenance.some((entry) => typeof entry !== 'string' || !entry)) {
|
||||
throw new TorchError('provenance must be a non-empty ordered string list', { code: 'ADOPTION_INPUT_INVALID' });
|
||||
}
|
||||
provenanceDigest = digest(provenance);
|
||||
if (sha(provenanceManifestDigest, 'provenanceManifestDigest') !== provenanceDigest) blockers.push({ code: 'ADOPTION_PROVENANCE_DIGEST_MISMATCH', expected: provenanceDigest });
|
||||
} catch (error) {
|
||||
blockers.push({ code: error.code ?? 'ADOPTION_INPUT_INVALID', details: error.details ?? null });
|
||||
}
|
||||
|
||||
const target = targetBranch ?? this.policy.target;
|
||||
if (typeof target !== 'string' || !target.trim()) blockers.push({ code: 'ADOPTION_TARGET_REQUIRED' });
|
||||
const candidateReference = candidateRef ?? (id ? `refs/torch/integration-candidates/${id}` : null);
|
||||
const expectedReference = expectedOldRef ?? (target ? `refs/heads/${target}` : null);
|
||||
if (!candidateReference || !/^refs\/torch\/integration-candidates\/[A-Za-z0-9._-]+$/.test(candidateReference)) blockers.push({ code: 'ADOPTION_CANDIDATE_REF_INVALID' });
|
||||
if (!expectedReference || !/^refs\/heads\/[A-Za-z0-9._/-]+$/.test(expectedReference)) blockers.push({ code: 'ADOPTION_EXPECTED_REF_INVALID' });
|
||||
|
||||
let contributor; let destination;
|
||||
try { contributor = this.controlPlane.assertIdentity(text(contributorArea, 'contributorArea')); }
|
||||
catch (error) { blockers.push({ code: 'ADOPTION_CONTRIBUTOR_INVALID', details: error.code ?? null }); }
|
||||
try { destination = this.controlPlane.assertIdentity(text(destinationArea, 'destinationArea')); }
|
||||
catch (error) { blockers.push({ code: 'ADOPTION_DESTINATION_INVALID', details: error.code ?? null }); }
|
||||
if (contributor && destination && contributor === destination) blockers.push({ code: 'ADOPTION_IDENTITIES_MUST_DIFFER' });
|
||||
|
||||
const binding = { action, adoptionId: id, candidateSha: candidate, baseTargetSha: base, provenanceManifestDigest: provenanceDigest,
|
||||
contributorArea: contributor, destinationArea: destination, targetBranch: target, candidateRef: candidateReference,
|
||||
expectedOldRef: expectedReference, adapter: adapterBinding };
|
||||
const approval = approvalId ? this.controlPlane.database.prepare(`SELECT * FROM approval_requests WHERE id = ? AND project_id = ?`)
|
||||
.get(approvalId, this.controlPlane.projectId) : null;
|
||||
if (!approval) blockers.push({ code: 'ADOPTION_APPROVAL_MISSING' });
|
||||
else if (approval.revision !== approvalRevision) blockers.push({ code: 'ADOPTION_APPROVAL_REVISION_STALE', actual: approval.revision });
|
||||
else if (!exactAuthority(authorityFrom(approval), binding)) blockers.push({ code: 'ADOPTION_APPROVAL_BINDING_INVALID' });
|
||||
|
||||
if (candidate && git(this.repositoryRoot, ['cat-file', '-e', `${candidate}^{commit}`], { allowFailure: true }).status !== 0) blockers.push({ code: 'ADOPTION_CANDIDATE_MISSING' });
|
||||
if (base && git(this.repositoryRoot, ['cat-file', '-e', `${base}^{commit}`], { allowFailure: true }).status !== 0) blockers.push({ code: 'ADOPTION_BASE_MISSING' });
|
||||
if (candidate && base && git(this.repositoryRoot, ['merge-base', '--is-ancestor', base, candidate], { allowFailure: true }).status !== 0) blockers.push({ code: 'ADOPTION_CANDIDATE_NOT_DESCENDED_FROM_BASE' });
|
||||
if (expectedReference && base) {
|
||||
const current = this.refAdapter.readRefs([expectedReference])[expectedReference];
|
||||
if (!current) blockers.push({ code: 'ADOPTION_EXPECTED_REF_MISSING' });
|
||||
else if (current !== base) blockers.push({ code: 'ADOPTION_EXPECTED_REF_STALE', expected: base, actual: current });
|
||||
}
|
||||
const candidateExists = candidateReference && this.refAdapter.readRefs([candidateReference])[candidateReference] != null;
|
||||
if (['plan', 'prepare', 'finalize'].includes(action) && candidateExists) blockers.push({ code: 'ADOPTION_CANDIDATE_REF_ALREADY_EXISTS' });
|
||||
if (action === 'request' && !candidateExists) blockers.push({ code: 'ADOPTION_CANDIDATE_REF_MISSING' });
|
||||
|
||||
for (const worktree of listWorktreePaths(this.repositoryRoot)) {
|
||||
const entries = git(worktree, ['status', '--porcelain=v1', '--untracked-files=all']).stdout.split('\n').filter(Boolean);
|
||||
if (entries.length) blockers.push({ code: 'ADOPTION_WORKTREE_DIRTY', worktree, entries });
|
||||
}
|
||||
if (tableExists(this.controlPlane.database, 'worktree_guards')) {
|
||||
for (const guard of this.controlPlane.database.prepare(`SELECT area_id, guard_type, reason FROM worktree_guards WHERE project_id = ? AND released_at IS NULL`).all(this.controlPlane.projectId)) blockers.push({ code: 'ADOPTION_WORKTREE_GUARD_ACTIVE', guard });
|
||||
}
|
||||
if (tableExists(this.controlPlane.database, 'resource_leases')) {
|
||||
for (const lease of this.controlPlane.database.prepare(`SELECT id, resource_id, area_id FROM resource_leases WHERE released_at IS NULL`).all()) blockers.push({ code: 'ADOPTION_RESOURCE_LEASE_ACTIVE', lease });
|
||||
}
|
||||
if (tableExists(this.controlPlane.database, 'prepared_checks')) {
|
||||
for (const check of this.controlPlane.database.prepare(`SELECT id, area_id, state FROM prepared_checks WHERE project_id = ? AND state IN ('prepared', 'running')`).all(this.controlPlane.projectId)) blockers.push({ code: 'ADOPTION_PREPARED_CHECK_ACTIVE', check });
|
||||
}
|
||||
for (const area of [contributor, destination].filter(Boolean)) {
|
||||
const executor = observeRuntimeTurn({ stateRoot: this.controlPlane.stateRoot, repositoryRoot: this.repositoryRoot,
|
||||
projectId: this.controlPlane.projectId, areaId: area, now: this.clock });
|
||||
if (executor.state !== 'inactive') blockers.push({ code: 'ADOPTION_EXECUTOR_NOT_INACTIVE', areaId: area, executor });
|
||||
}
|
||||
return { action: `plan-adoption-${action}`, adoptionId: id, approval: approval ? { id: approval.id, revision: approval.revision, status: approval.status, approver: approval.approver_id } : null,
|
||||
authority: binding, candidateSha: candidate ?? null, baseTargetSha: base ?? null, provenanceManifestDigest: provenanceDigest ?? null,
|
||||
contributorArea: contributor ?? null, destinationArea: destination ?? null, targetBranch: target ?? null, candidateRef: candidateReference,
|
||||
expectedOldRef: expectedReference, blockers, canProceed: blockers.length === 0, mutationPerformed: false };
|
||||
}
|
||||
|
||||
#append(adoptionId, expectedRevision, record, operation) {
|
||||
this.controlPlane.database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
this.#appendInTransaction(adoptionId, expectedRevision, record, operation);
|
||||
this.controlPlane.database.exec('COMMIT');
|
||||
} catch (error) { this.controlPlane.database.exec('ROLLBACK'); throw error; }
|
||||
}
|
||||
|
||||
#appendInTransaction(adoptionId, expectedRevision, record, operation) {
|
||||
const current = this.#find(adoptionId);
|
||||
if (expectedRevision === null ? current : !current || current.revision !== expectedRevision) {
|
||||
throw new TorchError('Adoption journal changed before this transition could be recorded', {
|
||||
code: 'ADOPTION_RECORD_REVISION_STALE', details: { adoptionId, expectedRevision, actual: current?.revision ?? null },
|
||||
});
|
||||
}
|
||||
this.controlPlane.database.prepare(`INSERT INTO audit_events
|
||||
(id, project_id, actor_id, operation, entity_type, entity_id, details, created_at)
|
||||
VALUES (?, ?, 'owner', ?, 'integration-adoption', ?, ?, ?)`)
|
||||
.run(this.idFactory(), this.controlPlane.projectId, operation, adoptionId, JSON.stringify(record), this.clock().toISOString());
|
||||
}
|
||||
|
||||
#fromRecord(record, input, action) {
|
||||
exactInput(record, input);
|
||||
return this.plan({ action, actorId: input.actorId, approvalId: input.approvalId, approvalRevision: input.approvalRevision,
|
||||
adoptionId: record.id, candidateSha: record.candidateSha, baseTargetSha: record.baseTargetSha, provenance: record.provenance,
|
||||
provenanceManifestDigest: record.provenanceManifestDigest, contributorArea: record.contributorArea, destinationArea: record.destinationArea,
|
||||
targetBranch: record.targetBranch, candidateRef: record.candidateRef, expectedOldRef: record.expectedOldRef, adapter: input.adapter });
|
||||
}
|
||||
|
||||
prepare(input = {}) {
|
||||
const adoptionId = input.adoptionId ?? this.idFactory();
|
||||
const plan = this.plan({ ...input, action: 'prepare', adoptionId });
|
||||
if (!plan.canProceed) throw new TorchError('Adoption inputs are not eligible for preparation', { code: 'ADOPTION_PREPARE_BLOCKED', details: plan.blockers });
|
||||
const now = this.clock().toISOString();
|
||||
const record = { schema: ADOPTION_SCHEMA, id: adoptionId, revision: 1, state: 'prepared', approvalId: text(input.approvalId, 'approvalId'),
|
||||
approvalRevision: input.approvalRevision, authority: plan.authority, provenance: [...input.provenance], candidateSha: plan.candidateSha,
|
||||
baseTargetSha: plan.baseTargetSha, provenanceManifestDigest: plan.provenanceManifestDigest, candidateRef: plan.candidateRef,
|
||||
expectedOldRef: plan.expectedOldRef, contributorArea: plan.contributorArea, destinationArea: plan.destinationArea, targetBranch: plan.targetBranch,
|
||||
archiveRef: `refs/torch/integration-archives/${adoptionId}`, preparedAt: now, finalizedAt: null, rolledBackAt: null };
|
||||
this.#append(adoptionId, null, record, 'integration.adoption.prepare');
|
||||
return { ...record, mutationPerformed: true };
|
||||
}
|
||||
|
||||
finalize(input = {}) {
|
||||
const record = this.getPrepared(text(input.adoptionId, 'adoptionId'));
|
||||
if (record.state !== 'prepared') throw new TorchError('Only a prepared adoption can be finalized', { code: 'ADOPTION_FINALIZE_STATE_INVALID', details: { state: record.state } });
|
||||
const plan = this.#fromRecord(record, input, 'finalize');
|
||||
if (!plan.canProceed) throw new TorchError('Adoption finalize is blocked', { code: 'ADOPTION_FINALIZE_BLOCKED', details: plan.blockers });
|
||||
const receipt = this.refAdapter.compareAndSwapRefs({ expected: [{ ref: record.expectedOldRef, sha: record.baseTargetSha }, { ref: record.candidateRef, sha: null }, { ref: record.archiveRef, sha: null }],
|
||||
writes: [{ ref: record.archiveRef, sha: record.baseTargetSha }, { ref: record.candidateRef, sha: record.candidateSha }] });
|
||||
const finalized = { ...record, revision: record.revision + 1, state: 'finalized', finalizeApprovalId: text(input.approvalId, 'approvalId'),
|
||||
finalizeApprovalRevision: input.approvalRevision, finalizedAt: this.clock().toISOString(), refReceiptDigest: digest(receipt) };
|
||||
this.#append(record.id, record.revision, finalized, 'integration.adoption.finalize');
|
||||
return { ...finalized, mutationPerformed: true };
|
||||
}
|
||||
|
||||
reconcile(input = {}) {
|
||||
const record = this.getPrepared(text(input.adoptionId, 'adoptionId'));
|
||||
const plan = this.#fromRecord(record, input, 'reconcile');
|
||||
if (!plan.canProceed) return { record, plan, state: 'blocked', mutationPerformed: false };
|
||||
const refs = this.refAdapter.readRefs([record.expectedOldRef, record.candidateRef, record.archiveRef]);
|
||||
const finalizedRefs = refs[record.expectedOldRef] === record.baseTargetSha && refs[record.candidateRef] === record.candidateSha && refs[record.archiveRef] === record.baseTargetSha;
|
||||
if (record.state === 'prepared' && finalizedRefs) {
|
||||
const recovered = { ...record, revision: record.revision + 1, state: 'finalized', finalizedAt: this.clock().toISOString(),
|
||||
recoveredAt: this.clock().toISOString(), recovery: 'private-ref-cas-completed-before-journal' };
|
||||
this.#append(record.id, record.revision, recovered, 'integration.adoption.reconcile-finalized');
|
||||
return { record: recovered, refs, state: 'recovered-finalized', mutationPerformed: true };
|
||||
}
|
||||
if (record.state === 'finalized' && finalizedRefs) return { record, refs, state: 'consistent', mutationPerformed: false };
|
||||
if (record.state === 'prepared' && refs[record.candidateRef] === null && refs[record.archiveRef] === null) return { record, refs, state: 'prepared', mutationPerformed: false };
|
||||
return { record, refs, state: 'diverged', mutationPerformed: false };
|
||||
}
|
||||
|
||||
rollback(input = {}) {
|
||||
const record = this.getPrepared(text(input.adoptionId, 'adoptionId'));
|
||||
if (record.state !== 'finalized') throw new TorchError('Only a finalized adoption can be rolled back', { code: 'ADOPTION_ROLLBACK_STATE_INVALID', details: { state: record.state } });
|
||||
const plan = this.#fromRecord(record, input, 'rollback');
|
||||
if (!plan.canProceed) throw new TorchError('Adoption rollback is blocked', { code: 'ADOPTION_ROLLBACK_BLOCKED', details: plan.blockers });
|
||||
const refs = this.refAdapter.readRefs([record.expectedOldRef, record.candidateRef, record.archiveRef]);
|
||||
const recovered = refs[record.expectedOldRef] === record.baseTargetSha
|
||||
&& refs[record.candidateRef] === null && refs[record.archiveRef] === record.baseTargetSha;
|
||||
if (!recovered && refs[record.candidateRef] !== record.candidateSha) {
|
||||
throw new TorchError('Candidate ref changed before rollback', { code: 'ADOPTION_ROLLBACK_REF_DIVERGED', details: refs });
|
||||
}
|
||||
const receipt = recovered ? { adapter: { name: this.refAdapter.name, id: this.refAdapter.id }, recovered: true, refs }
|
||||
: this.refAdapter.compareAndSwapRefs({ expected: [{ ref: record.candidateRef, sha: record.candidateSha }], writes: [{ ref: record.candidateRef, sha: null }] });
|
||||
const rolledBack = { ...record, revision: record.revision + 1, state: 'rolled-back', rollbackApprovalId: text(input.approvalId, 'approvalId'),
|
||||
rollbackApprovalRevision: input.approvalRevision, rolledBackAt: this.clock().toISOString(), rollbackReceiptDigest: digest(receipt),
|
||||
...(recovered ? { recoveredAt: this.clock().toISOString(), recovery: 'private-ref-rollback-completed-before-journal' } : {}) };
|
||||
this.#append(record.id, record.revision, rolledBack, 'integration.adoption.rollback');
|
||||
return { ...rolledBack, mutationPerformed: true };
|
||||
}
|
||||
|
||||
requestAdoptedCandidate(input = {}, { requestIntegration, findIntegrationRequest } = {}) {
|
||||
if (typeof requestIntegration !== 'function') throw new TorchError('Native integration request adapter is unavailable', { code: 'ADOPTION_NATIVE_REQUEST_UNAVAILABLE' });
|
||||
const adoptionId = text(input.adoptionId, 'adoptionId');
|
||||
this.controlPlane.database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const record = this.getPrepared(adoptionId);
|
||||
exactInput(record, input);
|
||||
if (!['finalized', 'requesting'].includes(record.state)) {
|
||||
throw new TorchError('Only a finalized adoption can request native qualification', {
|
||||
code: 'ADOPTION_REQUEST_STATE_INVALID', details: { state: record.state },
|
||||
});
|
||||
}
|
||||
const plan = this.#fromRecord(record, input, 'request');
|
||||
if (!plan.canProceed) throw new TorchError('Adopted candidate request is blocked', { code: 'ADOPTION_REQUEST_BLOCKED', details: plan.blockers });
|
||||
const requesting = record.state === 'requesting' ? record : { ...record, revision: record.revision + 1, state: 'requesting',
|
||||
requestApprovalId: text(input.approvalId, 'approvalId'), requestApprovalRevision: input.approvalRevision, requestStartedAt: this.clock().toISOString() };
|
||||
if (record.state !== 'requesting') this.#appendInTransaction(record.id, record.revision, requesting, 'integration.adoption.request-started');
|
||||
const existing = findIntegrationRequest?.({ candidateRef: requesting.candidateRef, commit: requesting.candidateSha });
|
||||
if (existing && (existing.sourceArea !== requesting.contributorArea || existing.targetBranch !== requesting.targetBranch)) {
|
||||
throw new TorchError('Existing private-ref integration request does not match this adoption', {
|
||||
code: 'ADOPTION_INTEGRATION_REQUEST_CONFLICT', details: { adoptionId: requesting.id, integrationRequestId: existing.id },
|
||||
});
|
||||
}
|
||||
const request = existing ?? requestIntegration({ adoptionId: requesting.id, areaId: requesting.contributorArea,
|
||||
commit: requesting.candidateSha, candidateRef: requesting.candidateRef });
|
||||
const requested = { ...requesting, revision: requesting.revision + 1, state: 'requested', integrationRequestId: request.id, requestedAt: this.clock().toISOString() };
|
||||
this.#appendInTransaction(requesting.id, requesting.revision, requested, 'integration.adoption.request');
|
||||
this.controlPlane.database.exec('COMMIT');
|
||||
return { ...requested, integrationRequest: request, mutationPerformed: true };
|
||||
} catch (error) {
|
||||
this.controlPlane.database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
#find(adoptionId) {
|
||||
const row = this.controlPlane.database.prepare(`SELECT details FROM audit_events WHERE project_id = ?
|
||||
AND entity_type = 'integration-adoption' AND entity_id = ? ORDER BY rowid DESC LIMIT 1`).get(this.controlPlane.projectId, adoptionId);
|
||||
return durableRecord(row);
|
||||
}
|
||||
|
||||
getPrepared(adoptionId) {
|
||||
const id = text(adoptionId, 'adoptionId');
|
||||
const record = this.#find(id);
|
||||
if (!record) throw new TorchError('Unknown adoption journal record', { code: 'ADOPTION_NOT_FOUND', details: { adoptionId: id } });
|
||||
return record;
|
||||
}
|
||||
}
|
||||
+101
-1
@@ -4,6 +4,7 @@ import { TorchError } from '../kernel/errors.mjs';
|
||||
import { readInstallManifest } from '../kernel/install.mjs';
|
||||
import { loadFleetDefinition } from '../kernel/worktrees.mjs';
|
||||
import { BacklogService } from '../backlog/service.mjs';
|
||||
import { AdoptionService } from './adoption.mjs';
|
||||
|
||||
function git(root, args, { allowFailure = false } = {}) {
|
||||
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
||||
@@ -15,6 +16,21 @@ function git(root, args, { allowFailure = false } = {}) {
|
||||
return { status: result.status, stdout: result.stdout?.trim() ?? '', stderr: result.stderr?.trim() ?? '' };
|
||||
}
|
||||
|
||||
function text(value, name) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new TorchError(`${name} is required`, { code: 'INTEGRATION_INPUT_INVALID', details: { name } });
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function sha(value, name) {
|
||||
const normalized = text(value, name);
|
||||
if (!/^[0-9a-f]{40,64}$/.test(normalized)) {
|
||||
throw new TorchError(`${name} must be a full Git object id`, { code: 'INTEGRATION_INPUT_INVALID', details: { name } });
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function integrationRow(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
@@ -42,6 +58,8 @@ export class IntegrationService {
|
||||
this.worktrees = new Map((manifest.external ?? [])
|
||||
.filter((entry) => entry.type === 'worktree')
|
||||
.map((entry) => [entry.area, entry]));
|
||||
this.adoption = new AdoptionService({ repositoryRoot, controlPlane, clock, idFactory,
|
||||
policy: { target: this.policy.target } });
|
||||
this.controlPlane.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS integration_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -84,12 +102,56 @@ export class IntegrationService {
|
||||
code: 'INTEGRATION_SOURCE_MOVED', details: { branchTip, sourceCommit },
|
||||
});
|
||||
}
|
||||
return this.#registerRequest({ area, sourceBranch: worktree.branch, sourceCommit });
|
||||
}
|
||||
|
||||
requestCandidate({ areaId, commit, candidateRef } = {}) {
|
||||
throw new TorchError('Private candidate intake requires an authenticated finalized adoption', {
|
||||
code: 'INTEGRATION_ADOPTED_INTAKE_INTERNAL_ONLY', details: { areaId, commit, candidateRef },
|
||||
});
|
||||
}
|
||||
|
||||
#requestFinalizedCandidate({ adoptionId, areaId, commit, candidateRef } = {}) {
|
||||
const area = this.controlPlane.assertIdentity(areaId);
|
||||
if (area === 'session-manager') {
|
||||
throw new TorchError('Session Manager does not own a feature worktree by default', {
|
||||
code: 'INTEGRATION_SOURCE_INVALID',
|
||||
});
|
||||
}
|
||||
const adoption = this.adoption.getPrepared(text(adoptionId, 'adoptionId'));
|
||||
if (adoption.state !== 'requesting' || adoption.contributorArea !== area
|
||||
|| adoption.candidateSha !== commit || adoption.candidateRef !== candidateRef) {
|
||||
throw new TorchError('Private candidate does not match an authenticated finalized adoption', {
|
||||
code: 'INTEGRATION_ADOPTED_BINDING_INVALID', details: { adoptionId, state: adoption.state },
|
||||
});
|
||||
}
|
||||
const ref = text(candidateRef, 'candidateRef');
|
||||
if (!/^refs\/torch\/integration-candidates\/[A-Za-z0-9._-]+$/.test(ref)) {
|
||||
throw new TorchError('Adopted candidates must use a private integration candidate ref', {
|
||||
code: 'INTEGRATION_ADOPTED_REF_INVALID', details: { candidateRef: ref },
|
||||
});
|
||||
}
|
||||
const sourceCommit = sha(commit, 'commit');
|
||||
const refTip = git(this.repositoryRoot, ['rev-parse', '--verify', ref], { allowFailure: true });
|
||||
if (refTip.status !== 0 || refTip.stdout !== sourceCommit) {
|
||||
throw new TorchError('Adopted candidate ref no longer names the exact requested commit', {
|
||||
code: 'INTEGRATION_ADOPTED_REF_STALE', details: { candidateRef: ref, sourceCommit, actual: refTip.stdout || null },
|
||||
});
|
||||
}
|
||||
const existing = this.controlPlane.database.prepare(`SELECT id FROM integration_requests
|
||||
WHERE project_id = ? AND source_branch = ? AND source_commit = ? ORDER BY rowid LIMIT 1`)
|
||||
.get(this.controlPlane.projectId, ref, sourceCommit);
|
||||
if (existing) return this.get(existing.id);
|
||||
return this.#registerRequest({ area, sourceBranch: ref, sourceCommit });
|
||||
}
|
||||
|
||||
#registerRequest({ area, sourceBranch, sourceCommit }) {
|
||||
const targetBranch = this.policy.target;
|
||||
const baseTargetCommit = git(this.repositoryRoot, ['rev-parse', targetBranch]).stdout;
|
||||
const now = this.clock().toISOString();
|
||||
const record = {
|
||||
id: this.idFactory(), projectId: this.controlPlane.projectId, sourceArea: area,
|
||||
sourceBranch: worktree.branch, sourceCommit, targetBranch, baseTargetCommit,
|
||||
sourceBranch, sourceCommit, targetBranch, baseTargetCommit,
|
||||
state: 'testing', reason: null, requiredChecks: this.policy.required_checks ?? [],
|
||||
authorizedBy: null, authorizedAt: null, createdAt: now, updatedAt: now, landedAt: null,
|
||||
};
|
||||
@@ -110,6 +172,44 @@ export class IntegrationService {
|
||||
return this.evaluate(record.id);
|
||||
}
|
||||
|
||||
planAdoption(input = {}) {
|
||||
return this.adoption.plan(input);
|
||||
}
|
||||
|
||||
prepareAdoption(input = {}) {
|
||||
return this.adoption.prepare(input);
|
||||
}
|
||||
|
||||
getPreparedAdoption(adoptionId) {
|
||||
return this.adoption.getPrepared(adoptionId);
|
||||
}
|
||||
|
||||
finalizeAdoption(input = {}) {
|
||||
return this.adoption.finalize(input);
|
||||
}
|
||||
|
||||
reconcileAdoption(input = {}) {
|
||||
return this.adoption.reconcile(input);
|
||||
}
|
||||
|
||||
rollbackAdoption(input = {}) {
|
||||
return this.adoption.rollback(input);
|
||||
}
|
||||
|
||||
requestAdoptedCandidate(input = {}) {
|
||||
return this.adoption.requestAdoptedCandidate(input, {
|
||||
requestIntegration: ({ adoptionId, areaId, commit, candidateRef }) => this.#requestFinalizedCandidate({ adoptionId, areaId, commit, candidateRef }),
|
||||
findIntegrationRequest: ({ candidateRef, commit }) => this.findCandidateRequest({ candidateRef, commit }),
|
||||
});
|
||||
}
|
||||
|
||||
findCandidateRequest({ candidateRef, commit } = {}) {
|
||||
const row = this.controlPlane.database.prepare(`SELECT id FROM integration_requests
|
||||
WHERE project_id = ? AND source_branch = ? AND source_commit = ? ORDER BY rowid LIMIT 1`)
|
||||
.get(this.controlPlane.projectId, candidateRef, commit);
|
||||
return row ? this.get(row.id) : null;
|
||||
}
|
||||
|
||||
get(requestId) {
|
||||
const row = this.controlPlane.database.prepare('SELECT * FROM integration_requests WHERE id = ?').get(requestId);
|
||||
if (!row) throw new TorchError(`Unknown integration request: ${requestId}`, { code: 'INTEGRATION_NOT_FOUND' });
|
||||
|
||||
+26
-3
@@ -19,6 +19,7 @@ export const TORCH_CORE_MCP_TOOL_NAMES = Object.freeze([
|
||||
'torch_who_owns',
|
||||
'torch_neighbours',
|
||||
'torch_send_message',
|
||||
'torch_reply_to_owner',
|
||||
'torch_read_messages',
|
||||
'torch_ack_message',
|
||||
'torch_report_status',
|
||||
@@ -100,6 +101,19 @@ function claimedIdentity(controlPlane, actorId, claim, field) {
|
||||
});
|
||||
}
|
||||
|
||||
function boundActorIdentity(controlPlane, actorId) {
|
||||
if (typeof actorId !== 'string' || actorId.trim().length === 0) {
|
||||
throw new TorchError('torch_reply_to_owner requires an actual bound Fleet identity', {
|
||||
code: 'FLEET_IDENTITY_REQUIRED', details: { field: 'bound_actor' },
|
||||
});
|
||||
}
|
||||
return controlPlane.assertIdentity(actorId);
|
||||
}
|
||||
|
||||
function inputSchema(tool) {
|
||||
return typeof tool.schema?.safeParse === 'function' ? tool.schema : z.object(tool.schema);
|
||||
}
|
||||
|
||||
export function createTorchToolset(controlPlane, {
|
||||
actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService,
|
||||
evolutionService, hierarchyService, repositoryRoot,
|
||||
@@ -169,6 +183,15 @@ export function createTorchToolset(controlPlane, {
|
||||
sender: claimedIdentity(controlPlane, actorId, sender, 'sender'), recipient, body, references,
|
||||
}),
|
||||
}],
|
||||
['torch_reply_to_owner', {
|
||||
description: 'Reply to one owner-originated request as the bound Fleet identity. This tool cannot select a sender, recipient, owner, or conversation history.',
|
||||
schema: z.object({
|
||||
request_id: z.string().min(1), body: z.string().min(1),
|
||||
}).strict(),
|
||||
invoke: ({ request_id: requestId, body }) => controlPlane.sendOwnerReply({
|
||||
sender: boundActorIdentity(controlPlane, actorId), requestId, body,
|
||||
}),
|
||||
}],
|
||||
['torch_read_messages', {
|
||||
description: 'Read durable direct and group messages visible to one Fleet identity.',
|
||||
schema: {
|
||||
@@ -736,14 +759,14 @@ export function describeTorchTools() {
|
||||
}).entries()].map(([name, tool]) => ({
|
||||
name,
|
||||
description: tool.description,
|
||||
inputSchema: z.toJSONSchema(z.object(tool.schema)),
|
||||
inputSchema: z.toJSONSchema(inputSchema(tool)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function callTorchTool(controlPlane, name, input = {}, options = {}) {
|
||||
const tool = createTorchToolset(controlPlane, options).get(name);
|
||||
if (!tool) throw new Error(`Unknown TORCH MCP tool: ${name}`);
|
||||
const parsed = z.object(tool.schema).parse(input);
|
||||
const parsed = inputSchema(tool).parse(input);
|
||||
return tool.invoke(parsed);
|
||||
}
|
||||
|
||||
@@ -762,7 +785,7 @@ export function createTorchMcpServer(controlPlane, {
|
||||
actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService,
|
||||
evolutionService, hierarchyService, repositoryRoot, artifactService,
|
||||
})) {
|
||||
server.registerTool(name, { description: tool.description, inputSchema: tool.schema }, async (input) => {
|
||||
server.registerTool(name, { description: tool.description, inputSchema: inputSchema(tool) }, async (input) => {
|
||||
try {
|
||||
const result = await tool.invoke(input);
|
||||
return {
|
||||
|
||||
@@ -16,6 +16,8 @@ import { assessBacklogHealth, backlogObservedCommitDistance } from '../backlog/s
|
||||
import { observeTaskActivity } from '../backlog/activity.mjs';
|
||||
import { ScheduleLauncherService } from '../schedules/launcher.mjs';
|
||||
import { hierarchyOrder } from '../runtime/hierarchy-order.mjs';
|
||||
import { observeRuntimeTurn } from '../runtime/turn-guard.mjs';
|
||||
import { observeContinuation } from '../runtime/continuation.mjs';
|
||||
|
||||
function json(path) {
|
||||
return JSON.parse(readFileSync(path, 'utf8'));
|
||||
@@ -403,6 +405,7 @@ export function observeProject({ repositoryRoot, env = process.env, now = () =>
|
||||
metrics: ['cachedInput', 'uncachedInput', 'cacheCreation', 'cacheRead', 'compactions', 'resumedPromptBytes', 'costPerVerifiedItem'],
|
||||
},
|
||||
};
|
||||
result.continuation = observeContinuation({ stateRoot, tasks: result.backlog, now });
|
||||
result.backlogHealth = backlogHealth(repository.root, result.backlog, result.agents, now);
|
||||
result.backlogActivity = observeBacklogActivity({ repositoryRoot: repository.root,
|
||||
tasks: result.backlog, config, manifest, now: now() });
|
||||
@@ -417,11 +420,21 @@ export function observeProject({ repositoryRoot, env = process.env, now = () =>
|
||||
`).all().map((row) => [row.area_id, row]));
|
||||
result.agents = result.agents.map((agent) => {
|
||||
const row = live.get(agent.areaId);
|
||||
return row ? {
|
||||
const reported = row ? {
|
||||
...agent, state: row.state, runtime: row.runtime ?? agent.runtime,
|
||||
runtimeSessionId: row.runtime_session_id, summary: row.summary,
|
||||
task: row.task, heartbeatAt: row.heartbeat_at,
|
||||
} : agent;
|
||||
const executor = observeRuntimeTurn({ stateRoot, repositoryRoot: repository.root,
|
||||
projectId: manifest.projectId, areaId: agent.areaId, now });
|
||||
const staleIdle = ['idle', 'offline'].includes(reported.state);
|
||||
return { ...reported, reportedState: reported.state, reportedSummary: reported.summary, executor,
|
||||
...(executor.state === 'active' && staleIdle ? {
|
||||
state: executor.phase,
|
||||
summary: `Live runtime executor is ${executor.phase} (last reported state: ${reported.state}).`,
|
||||
} : executor.state === 'unknown' && staleIdle ? {
|
||||
state: 'unknown', summary: 'Runtime turn guard exists, but its executor could not be verified.',
|
||||
} : {}) };
|
||||
});
|
||||
}
|
||||
if (hasTable(database, 'schedule_wake_reservations')) {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { TorchError } from '../kernel/errors.mjs';
|
||||
|
||||
function statePath(control) { return join(control.stateRoot, 'continuation.json'); }
|
||||
function occupiedAreas(control) {
|
||||
const path = join(control.stateRoot, 'sessions', 'turn-guards');
|
||||
return existsSync(path) ? readdirSync(path, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name) : [];
|
||||
}
|
||||
function taskKey(tasks) { return JSON.stringify(tasks.map(t => [t.id, t.revision]).sort((a, b) => a[0].localeCompare(b[0]))); }
|
||||
function dailyCap(state, day) {
|
||||
return state.dailyOverride?.day === day ? Math.max(state.maxTurnsPerDay, state.dailyOverride.maxTurns) : state.maxTurnsPerDay;
|
||||
}
|
||||
function read(control) {
|
||||
const policyPath = join(control.stateRoot, 'continuation-policy.json');
|
||||
const state = { enabled: false, maxTurnsPerDay: 12, maxConcurrency: 3, areas: [], day: null, attempts: 0, handled: {}, progress: {},
|
||||
...(existsSync(statePath(control)) ? JSON.parse(readFileSync(statePath(control), 'utf8')) : {}),
|
||||
...(existsSync(policyPath) ? JSON.parse(readFileSync(policyPath, 'utf8')) : {}) };
|
||||
if (typeof state.enabled !== 'boolean' || !Number.isInteger(state.maxTurnsPerDay)
|
||||
|| state.maxTurnsPerDay < 1 || state.maxTurnsPerDay > 48 || !Array.isArray(state.areas)
|
||||
|| !Number.isInteger(state.maxConcurrency) || state.maxConcurrency < 1 || state.maxConcurrency > 7
|
||||
|| !Number.isInteger(state.attempts) || state.attempts < 0 || !state.handled || typeof state.handled !== 'object'
|
||||
|| !state.progress || typeof state.progress !== 'object'
|
||||
|| (state.dailyOverride != null && (!/^\d{4}-\d{2}-\d{2}$/.test(state.dailyOverride.day)
|
||||
|| !Number.isInteger(state.dailyOverride.maxTurns) || state.dailyOverride.maxTurns < 1 || state.dailyOverride.maxTurns > 96))) {
|
||||
throw new TorchError('Continuation policy or attempt ledger is invalid; launch refused.', { code: 'CONTINUATION_POLICY_INVALID' });
|
||||
}
|
||||
return state;
|
||||
}
|
||||
function save(control, state, policy = false) {
|
||||
const path = policy ? join(control.stateRoot, 'continuation-policy.json') : statePath(control);
|
||||
const temporary = `${path}.${randomUUID()}.tmp`;
|
||||
const value = policy ? { enabled: state.enabled, maxTurnsPerDay: state.maxTurnsPerDay, maxConcurrency: state.maxConcurrency, areas: state.areas, dailyOverride: state.dailyOverride ?? null }
|
||||
: { day: state.day, attempts: state.attempts, handled: state.handled, progress: state.progress };
|
||||
writeFileSync(temporary, JSON.stringify(value), { flag: 'wx', mode: 0o600 });
|
||||
renameSync(temporary, path);
|
||||
}
|
||||
|
||||
export function configureContinuation(control, { actorId, enabled, maxTurnsPerDay, maxConcurrency, todayMaxTurns } = {}) {
|
||||
control.assertOwnerActor(actorId);
|
||||
const previous = read(control);
|
||||
maxTurnsPerDay ??= previous.maxTurnsPerDay;
|
||||
maxConcurrency ??= previous.maxConcurrency;
|
||||
if (typeof enabled !== 'boolean' || !Number.isInteger(maxTurnsPerDay) || maxTurnsPerDay < 1 || maxTurnsPerDay > 48
|
||||
|| !Number.isInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 7
|
||||
|| (todayMaxTurns !== undefined && (!Number.isInteger(todayMaxTurns) || todayMaxTurns < maxTurnsPerDay || todayMaxTurns > 96))) {
|
||||
throw new TorchError('Continuation requires explicit enable/pause, 1–48 daily turns, 1–7 concurrent turns and an optional same-day total between the daily cap and 96.', { code: 'CONTINUATION_POLICY_INVALID' });
|
||||
}
|
||||
const state = { ...previous, enabled, maxTurnsPerDay, maxConcurrency, areas: control.listAgents().map(a => a.areaId) };
|
||||
if (todayMaxTurns !== undefined) state.dailyOverride = { day: control.clock().toISOString().slice(0, 10), maxTurns: todayMaxTurns };
|
||||
save(control, state, true);
|
||||
control.auditOwnerAction({ actorId, operation: 'runtime.continuation.configure', entityType: 'project', entityId: control.projectId,
|
||||
details: { enabled, maxTurnsPerDay, maxConcurrency, dailyOverride: state.dailyOverride ?? null, areas: state.areas } });
|
||||
return { enabled, maxTurnsPerDay, maxConcurrency, dailyOverride: state.dailyOverride ?? null, areas: state.areas, sessionsStarted: false };
|
||||
}
|
||||
|
||||
export function observeContinuation({ stateRoot, tasks = [], now = () => new Date() } = {}) {
|
||||
try {
|
||||
const state = read({ stateRoot });
|
||||
const day = now().toISOString().slice(0, 10);
|
||||
const attempts = state.day === day ? state.attempts : 0;
|
||||
const cap = dailyCap(state, day);
|
||||
const remainingTurns = Math.max(0, cap - attempts);
|
||||
const held = Object.entries(state.progress).filter(([area, progress]) => {
|
||||
const active = tasks.filter(t => t.owner === area && ['assigned', 'in_progress'].includes(t.state));
|
||||
return active.length && progress?.status === 0 && progress.noProgress >= 2 && progress.taskKey === taskKey(active);
|
||||
}).map(([areaId]) => ({ areaId, reason: 'no-progress-needs-coordination' }));
|
||||
return { available: existsSync(join(stateRoot, 'continuation-policy.json')), enabled: state.enabled,
|
||||
day, dayTimezone: 'UTC', attempts, maxTurnsPerDay: cap, configuredMaxTurnsPerDay: state.maxTurnsPerDay,
|
||||
dailyOverride: state.dailyOverride ?? null, maxConcurrency: state.maxConcurrency, remainingTurns,
|
||||
stopReason: !state.enabled ? 'paused' : remainingTurns === 0 ? 'daily-turn-cap' : null,
|
||||
capacityHeldBy: occupiedAreas({ stateRoot }), held,
|
||||
budgetKind: 'automatic-turn-cap', manualTurnsIncluded: false, tokenOrCostGovernance: 'unknown',
|
||||
timerInstallationVerified: false, mutationPerformed: false };
|
||||
} catch {
|
||||
return { available: false, stopReason: 'policy-or-ledger-unreadable', mutationPerformed: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function planContinuation(control, backlog, { at = new Date(), limit } = {}) {
|
||||
const state = read(control);
|
||||
const day = at.toISOString().slice(0, 10);
|
||||
const attempts = state.day === day ? state.attempts : 0;
|
||||
const cap = dailyCap(state, day);
|
||||
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 7)) throw new TorchError('Continuation limit must be 1–7.', { code: 'CONTINUATION_POLICY_INVALID' });
|
||||
const capacity = Math.min(limit ?? state.maxConcurrency, state.maxConcurrency);
|
||||
const metadata = JSON.parse(readFileSync(join(control.stateRoot, 'project.json'), 'utf8'));
|
||||
if (!state.enabled || metadata.detachedAt || attempts >= cap) {
|
||||
return { enabled: state.enabled, maxConcurrency: capacity, reason: metadata.detachedAt ? 'detached' : !state.enabled ? 'paused' : 'daily-turn-cap', candidates: [], attempts };
|
||||
}
|
||||
const tasks = backlog.list();
|
||||
const candidates = [];
|
||||
const held = [];
|
||||
for (const areaId of state.areas) {
|
||||
const agent = control.getAgent(areaId);
|
||||
if (!['idle', 'offline', 'waiting'].includes(agent.state)
|
||||
|| existsSync(join(control.stateRoot, 'sessions', 'turn-guards', areaId))) continue;
|
||||
const messages = control.readMessages({ recipient: areaId, unacknowledgedOnly: true, limit: 1000 });
|
||||
const active = tasks.filter(t => t.owner === areaId && ['assigned', 'in_progress'].includes(t.state)
|
||||
&& (t.dependencies ?? []).every(id => tasks.find(t => t.id === id)?.state === 'completed'));
|
||||
const previous = state.handled[areaId] ?? {};
|
||||
const freshMessages = messages.filter(m => !previous.messageIds?.includes(m.id));
|
||||
const freshTasks = active.filter(t => previous.taskRevisions?.[t.id] !== t.revision);
|
||||
const progress = state.progress[areaId];
|
||||
const continueWork = active.length > 0 && progress?.status === 0
|
||||
&& progress.taskKey === taskKey(active) && progress.noProgress < 2;
|
||||
if (!freshMessages.length && !freshTasks.length && !continueWork) {
|
||||
if (active.length && progress?.noProgress >= 2) held.push({ areaId, reason: 'no-progress-needs-coordination', taskIds: active.map(t => t.id) });
|
||||
continue;
|
||||
}
|
||||
const signal = { messageIds: messages.map(m => m.id), taskRevisions: Object.fromEntries(active.map(t => [t.id, t.revision])) };
|
||||
const fingerprint = createHash('sha256').update(JSON.stringify(signal)).digest('hex');
|
||||
candidates.push({ areaId, fingerprint, signal, workKey: taskKey(active),
|
||||
messageCount: freshMessages.length, taskIds: active.map(t => t.id), continueWork });
|
||||
}
|
||||
const ordinal = area => state.handled[area]?.dispatchedDay === day ? state.handled[area].dispatchedOrdinal ?? 0 : 0;
|
||||
candidates.sort((a, b) => ordinal(a.areaId) - ordinal(b.areaId)
|
||||
|| Number(b.areaId === 'session-manager') - Number(a.areaId === 'session-manager'));
|
||||
return { enabled: true, maxConcurrency: capacity, day, attempts, remainingTurns: cap - attempts,
|
||||
occupiedAreas: occupiedAreas(control), held,
|
||||
candidates: candidates.slice(0, Math.min(capacity, cap - attempts)), mutationPerformed: false };
|
||||
}
|
||||
|
||||
export async function runContinuation(control, backlog, { actorId, launch, at, clock = () => at ?? new Date(), limit } = {}) {
|
||||
control.assertOwnerActor(actorId);
|
||||
if (typeof launch !== 'function') throw new TorchError('Continuation needs an authorized launcher.', { code: 'RUNTIME_EXECUTION_NOT_AUTHORIZED' });
|
||||
const lock = join(control.stateRoot, 'continuation.lock');
|
||||
try { mkdirSync(lock); } catch (error) {
|
||||
if (error.code === 'EEXIST') return { reason: 'controller-active-or-unreconciled', launched: [] };
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const pending = new Map();
|
||||
const launched = [];
|
||||
let reason = null;
|
||||
while (true) {
|
||||
// Refill immediately after EACH completion. Pending promises and physical
|
||||
// guards both count, including manual executors outside this controller.
|
||||
let current = planContinuation(control, backlog, { at: clock(), limit });
|
||||
let occupied = new Set([...occupiedAreas(control), ...pending.keys()]);
|
||||
while (occupied.size < current.maxConcurrency && current.enabled && !current.reason) {
|
||||
const candidate = current.candidates.find(c => !occupied.has(c.areaId));
|
||||
if (!candidate) break;
|
||||
const state = read(control);
|
||||
if (state.day !== current.day) { state.day = current.day; state.attempts = 0; }
|
||||
state.attempts++;
|
||||
state.handled[candidate.areaId] = { ...candidate.signal,
|
||||
dispatchedDay: current.day, dispatchedOrdinal: state.attempts };
|
||||
save(control, state); // Reserve before launch; unknown attempts are never refunded.
|
||||
let result;
|
||||
try { result = launch(candidate.areaId); } catch { result = null; }
|
||||
pending.set(candidate.areaId, Promise.resolve(result).catch(() => null)
|
||||
.then(status => ({ areaId: candidate.areaId, status, candidate })));
|
||||
current = planContinuation(control, backlog, { at: clock(), limit });
|
||||
occupied = new Set([...occupiedAreas(control), ...pending.keys()]);
|
||||
}
|
||||
reason = current.reason ?? (occupied.size >= current.maxConcurrency ? 'capacity-full' : null);
|
||||
if (!pending.size) break;
|
||||
const completed = await Promise.race(pending.values());
|
||||
pending.delete(completed.areaId);
|
||||
launched.push({ areaId: completed.areaId, status: completed.status });
|
||||
const state = read(control);
|
||||
const active = backlog.list().filter(t => t.owner === completed.areaId && ['assigned', 'in_progress'].includes(t.state));
|
||||
const key = taskKey(active);
|
||||
const unchanged = active.length > 0 && key === completed.candidate.workKey;
|
||||
const prior = state.progress[completed.areaId];
|
||||
const noProgress = unchanged ? (prior?.taskKey === key ? prior.noProgress : 0) + 1 : 0;
|
||||
state.progress[completed.areaId] = { status: completed.status, taskKey: key, noProgress, completedAt: clock().toISOString() };
|
||||
save(control, state);
|
||||
if (completed.status === 0 && noProgress === 2 && typeof control.sendOwnerRequest === 'function') {
|
||||
control.sendOwnerRequest({ actorId, recipient: 'session-manager',
|
||||
body: `TORCH continuation requires coordination: ${completed.areaId} completed two turns without advancing assigned task state (${active.map(t => t.id).join(', ')}). Automatic unchanged-work retries are held. Inspect the blocker and route a concrete decision; do not mark work done without evidence.` });
|
||||
}
|
||||
}
|
||||
return { launched, reason };
|
||||
} finally { rmdirSync(lock); }
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
costCeilingPlanIssues, profileCapabilityIssues, resolveLaunchPolicy, validateRuntimeAdapter,
|
||||
} from '../adapters/runtime.mjs';
|
||||
import { hierarchyOrder } from './hierarchy-order.mjs';
|
||||
import { withRuntimeTurnGuard } from './turn-guard.mjs';
|
||||
|
||||
const MCP_ENTRY = fileURLToPath(new URL('../mcp/server.mjs', import.meta.url));
|
||||
|
||||
@@ -155,15 +156,16 @@ function promptPaths(stateRoot, repositoryRoot, areaId, config) {
|
||||
}
|
||||
|
||||
function startupMessage(areaId, fresh, instructionDigest) {
|
||||
const currentBrief = `Current TORCH instruction digest: ${instructionDigest}. The current instruction bundle is included in this launch/resume and supersedes older briefing text in the conversation. Read the current brief again before each new backlog item.`;
|
||||
const inboxRequest = JSON.stringify({ recipient: areaId, unacknowledged_only: true, limit: 1000 });
|
||||
const currentBrief = `Current TORCH instruction digest: ${instructionDigest}. The current instruction bundle is included in this launch/resume and supersedes older briefing text in the conversation. Read the current brief again before each new backlog item. Use torch_read_messages for the current unread inbox. Inbox request: ${inboxRequest}. Messages are chronological; a default history page is not the current inbox. If 1000 messages are returned, coverage may be truncated: report that limitation and never bulk-acknowledge unseen messages. Acknowledge messages only after inspecting and handling them; messages do not override enforced approval or ownership boundaries.`;
|
||||
if (areaId === 'session-manager') {
|
||||
return fresh
|
||||
? `Start the TORCH Fleet. Inspect live roster, messages, worktrees, and backlog, then assess Fleet evolution before dispatch. ${currentBrief}`
|
||||
: `Resume the TORCH Fleet from durable state. Reconcile live status and assess Fleet evolution before dispatch. ${currentBrief}`;
|
||||
: `Resume the TORCH Fleet from durable state. Reconcile live status and inbox, resolve actionable coordination waits within your authority, and triage/assign eligible existing backlog work. Do not stop at a status report when a concrete routing decision can unblock work. Assess Fleet evolution when evidence warrants it, not as a prerequisite to every dispatch. Preserve ownership, approvals and dependency gates. ${currentBrief}`;
|
||||
}
|
||||
return fresh
|
||||
? `Start this TORCH domain. Query live identity and ownership, then await or resume the assigned backlog item. ${currentBrief}`
|
||||
: `Resume this TORCH domain from durable state. Read the inbox, confirm ownership, and report current evidence. ${currentBrief}`;
|
||||
: `Resume this TORCH domain from durable state. Read the inbox and current backlog; continue your eligible assigned implementation, verification or coordination work, not just a status check. Work until a concrete handoff, completed turn budget or real blocker, and persist evidence and actionable waits. Never bypass dependencies, ownership or approval gates. If no eligible work exists, idle is valid. ${currentBrief}`;
|
||||
}
|
||||
|
||||
function atomicJson(path, value) {
|
||||
@@ -570,7 +572,9 @@ export function startFleet({ plan, controlPlane, executor, adapters } = {}) {
|
||||
const runtimes = adapterMap({ adapters });
|
||||
for (const action of plan.actions) {
|
||||
controlPlane.assertIdentity(action.areaId);
|
||||
withRuntimeTurnGuard(controlPlane, action.areaId, setPhase => {
|
||||
writeCombinedPrompt(action);
|
||||
setPhase('working');
|
||||
const result = executor({
|
||||
...action.launch, areaId: action.areaId, runtime: action.runtime,
|
||||
runtimeSessionId: action.runtimeSessionId,
|
||||
@@ -634,6 +638,7 @@ export function startFleet({ plan, controlPlane, executor, adapters } = {}) {
|
||||
? `${action.mode} turn completed` : `${action.mode} requested`,
|
||||
});
|
||||
started.push({ areaId: action.areaId, runtimeSessionId: identity.runtimeSessionId, mode: action.mode });
|
||||
});
|
||||
}
|
||||
if (plan.stateRoot) {
|
||||
const metadata = projectMetadata(plan.stateRoot);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, readdirSync, readlinkSync, statSync } from 'node:fs';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { TorchError } from '../kernel/errors.mjs';
|
||||
|
||||
const issued = new WeakMap();
|
||||
const TTL_MS = 60_000;
|
||||
function refuse(message) {
|
||||
throw new TorchError(message, { code: 'RUNTIME_RECOVERY_UNVERIFIED' });
|
||||
}
|
||||
|
||||
export function readStoppedUnit(unit) {
|
||||
const result = spawnSync('systemctl', ['--user', 'show', unit,
|
||||
'--property=Id,ActiveState,SubState,MainPID,ControlPID,InvocationID,ExecStart,WorkingDirectory,ControlGroup,ExecMainExitTimestamp'],
|
||||
{ encoding: 'utf8', timeout: 5000, maxBuffer: 64 * 1024 });
|
||||
if (result.error || result.signal || result.status !== 0) refuse('Cannot verify the owner-selected service invocation.');
|
||||
return Object.fromEntries(result.stdout.trim().split('\n').map(line => {
|
||||
const index = line.indexOf('=');
|
||||
return [line.slice(0, index), line.slice(index + 1)];
|
||||
}));
|
||||
}
|
||||
|
||||
export function readNativeProcesses() {
|
||||
if (process.platform !== 'linux') refuse('Legacy process inspection is supported only on Linux.');
|
||||
const processes = [];
|
||||
for (const name of readdirSync('/proc').filter(value => /^\d+$/.test(value))) {
|
||||
const directory = join('/proc', name);
|
||||
try {
|
||||
if (statSync(directory).uid !== process.getuid()) continue;
|
||||
const argv = readFileSync(join(directory, 'cmdline'), 'utf8').split('\0').filter(Boolean);
|
||||
if (!argv.length) continue;
|
||||
const native = argv.some(value => /^(?:codex|claude|pi)(?:\.js)?$/.test(basename(value)));
|
||||
if (native) processes.push({ pid: Number(name), argv, cwd: readlinkSync(join(directory, 'cwd')) });
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT' && error.code !== 'ESRCH') refuse('Native process visibility is incomplete; recovery refused.');
|
||||
}
|
||||
}
|
||||
return processes;
|
||||
}
|
||||
|
||||
function verify(record) {
|
||||
const unit = record.readUnit(record.unit);
|
||||
const prefix = `torch-${record.projectId}-`;
|
||||
if (typeof record.unit !== 'string' || !record.unit.startsWith(prefix) || !/^[A-Za-z0-9_.-]+\.service$/.test(record.unit)
|
||||
|| unit.Id !== record.unit || !/^[a-f0-9]{32}$/.test(unit.InvocationID ?? '')
|
||||
|| unit.InvocationID !== record.invocationId
|
||||
|| !['failed', 'inactive'].includes(unit.ActiveState)
|
||||
|| !['failed', 'dead'].includes(unit.SubState)
|
||||
|| unit.MainPID !== '0' || unit.ControlPID !== '0' || unit.ControlGroup !== ''
|
||||
|| resolve(unit.WorkingDirectory || '/') !== record.repositoryRoot) {
|
||||
refuse('The selected project service is not a verified stopped invocation.');
|
||||
}
|
||||
// Explicit legacy operator binding, not a claim of cryptographic session provenance.
|
||||
// Exact area token and terminal timestamp must agree with the retained unknown row.
|
||||
if (!(unit.ExecStart ?? '').split(/[\s;]+/).includes(record.identity.areaId)
|
||||
|| !Number.isFinite(Date.parse(unit.ExecMainExitTimestamp))
|
||||
|| Math.abs(Date.parse(unit.ExecMainExitTimestamp) - Date.parse(record.identity.updatedAt)) > 5000) {
|
||||
refuse('Service area or terminal timestamp does not match the retained unknown identity.');
|
||||
}
|
||||
for (const processRecord of record.readProcesses()) {
|
||||
if (processRecord.argv.includes(record.identity.runtimeSessionId)
|
||||
|| processRecord.argv.includes(record.worktree)
|
||||
|| resolve(processRecord.cwd) === record.worktree) {
|
||||
refuse('A native process still references the selected session or workspace.');
|
||||
}
|
||||
}
|
||||
return { unit: record.unit, invocationId: unit.InvocationID,
|
||||
terminalAt: new Date(Date.parse(unit.ExecMainExitTimestamp)).toISOString(),
|
||||
outcome: 'unknown', verification: 'owner-selected-legacy-invocation' };
|
||||
}
|
||||
|
||||
export function issueStoppedExecutorEvidence({ controlPlane, actorId, areaId, unit, invocationId,
|
||||
readUnit = readStoppedUnit, readProcesses = readNativeProcesses, now = () => Date.now() } = {}) {
|
||||
controlPlane.assertOwnerActor(actorId);
|
||||
const identity = controlPlane.getAgent(areaId);
|
||||
if (identity.state !== 'working' || !identity.runtimeSessionId || !identity.updatedAt
|
||||
|| !identity.summary?.includes('Runtime executor completion is unknown')) {
|
||||
refuse('Only an exact retained working/unknown identity can use legacy recovery.');
|
||||
}
|
||||
const worktree = controlPlane.manifest.external?.find(entry => entry.type === 'worktree' && entry.area === areaId)?.path;
|
||||
if (!worktree) refuse('No registered workspace exists for this identity.');
|
||||
const record = { identity: Object.freeze({ areaId, state: identity.state, runtime: identity.runtime,
|
||||
runtimeSessionId: identity.runtimeSessionId, updatedAt: identity.updatedAt }),
|
||||
unit, invocationId, projectId: controlPlane.projectId, repositoryRoot: resolve(controlPlane.repositoryRoot),
|
||||
worktree: resolve(worktree), readUnit, readProcesses, now, issuedAt: now() };
|
||||
const facts = verify(record);
|
||||
const evidence = Object.freeze({});
|
||||
issued.set(evidence, record);
|
||||
return { evidence, plan: { areaId, expectedIdentity: record.identity, ...facts,
|
||||
mutationPerformed: false, effect: 'Recover presence to offline; preserve task, session and unknown outcome. No provider launch.' } };
|
||||
}
|
||||
|
||||
export function consumeStoppedExecutorEvidence(evidence, { projectId, repositoryRoot, identity } = {}) {
|
||||
const record = issued.get(evidence);
|
||||
if (!record) refuse('Recovery requires fresh Runtime-issued evidence, not caller text or a Boolean.');
|
||||
issued.delete(evidence);
|
||||
if (record.projectId !== projectId || record.repositoryRoot !== resolve(repositoryRoot)
|
||||
|| record.now() < record.issuedAt
|
||||
|| record.now() - record.issuedAt > TTL_MS
|
||||
|| ['areaId', 'state', 'runtime', 'runtimeSessionId', 'updatedAt'].some(key => record.identity[key] !== identity[key])) {
|
||||
refuse('Recovery evidence is stale, expired or belongs to another identity.');
|
||||
}
|
||||
const facts = verify(record); // Recheck physical state immediately before the transactional mutation.
|
||||
return { ...facts, digest: createHash('sha256').update(JSON.stringify({ identity: record.identity, ...facts })).digest('hex') };
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync, readlinkSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { TorchError } from '../kernel/errors.mjs';
|
||||
|
||||
function nativeProcess(pid) {
|
||||
if (process.platform !== 'linux') throw new Error('Process observation unavailable');
|
||||
const directory = `/proc/${pid}`;
|
||||
const stat = readFileSync(join(directory, 'stat'), 'utf8');
|
||||
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
||||
return { uid: statSync(directory).uid, state: fields[0], startTicks: fields[19],
|
||||
cwd: readlinkSync(join(directory, 'cwd')),
|
||||
argv: readFileSync(join(directory, 'cmdline'), 'utf8').split('\0').filter(Boolean) };
|
||||
}
|
||||
|
||||
// Observational only: never changes assignments, heartbeat, authority or launch eligibility.
|
||||
export function observeRuntimeTurn({ stateRoot, repositoryRoot, projectId, areaId,
|
||||
inspectProcess = nativeProcess, uid = process.getuid?.(), now = () => new Date() } = {}) {
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(areaId)) return { state: 'unknown', reason: 'invalid-identity' };
|
||||
let record;
|
||||
try { record = JSON.parse(readFileSync(join(stateRoot, 'sessions', 'turn-guards', areaId, 'owner.json'), 'utf8')); }
|
||||
catch (error) { return { state: error.code === 'ENOENT' ? 'inactive' : 'unknown', reason: 'guard-unavailable' }; }
|
||||
if (record.projectId !== projectId || record.areaId !== areaId || !Number.isSafeInteger(record.pid)
|
||||
|| record.pid < 1 || typeof record.nonce !== 'string' || !record.nonce) {
|
||||
return { state: 'unknown', reason: 'guard-invalid' };
|
||||
}
|
||||
try {
|
||||
const live = inspectProcess(record.pid);
|
||||
if (live.uid !== uid || ['Z', 'X'].includes(live.state)) return { state: 'unknown', reason: 'executor-not-live' };
|
||||
if (record.startTicks) {
|
||||
if (record.startTicks !== live.startTicks) return { state: 'unknown', reason: 'executor-replaced' };
|
||||
} else {
|
||||
// Existing pre-observation guards: require an exact identity-bound TORCH up process.
|
||||
const only = live.argv.indexOf('--only');
|
||||
if (resolve(live.cwd) !== resolve(repositoryRoot) || !live.argv.includes('up')
|
||||
|| only < 0 || live.argv[only + 1] !== areaId
|
||||
|| !live.argv.some(arg => ['torch.mjs', 'torch'].includes(basename(arg)))) {
|
||||
return { state: 'unknown', reason: 'legacy-executor-unverified' };
|
||||
}
|
||||
}
|
||||
return { state: 'active', phase: record.phase === 'starting' ? 'starting' : 'working',
|
||||
observedAt: now().toISOString(), source: 'live-process-and-turn-guard' };
|
||||
} catch { return { state: 'unknown', reason: 'executor-unobservable' }; }
|
||||
}
|
||||
|
||||
// One physical executor per identity, including the interval after an agent
|
||||
// reports idle but before its harness actually exits. Crashed guards never expire.
|
||||
export function withRuntimeTurnGuard(controlPlane, areaId, run) {
|
||||
controlPlane.assertIdentity(areaId);
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(areaId)) throw new TorchError('Invalid runtime guard identity.', { code: 'INVALID_RUNTIME_INPUT' });
|
||||
const parent = join(controlPlane.stateRoot, 'sessions', 'turn-guards');
|
||||
mkdirSync(parent, { recursive: true });
|
||||
const directory = join(parent, areaId);
|
||||
try { mkdirSync(directory); } catch (error) {
|
||||
if (error.code === 'EEXIST') throw new TorchError('An executor already holds this identity turn guard.', {
|
||||
code: 'RUNTIME_TURN_ACTIVE', details: { areaId },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const path = join(directory, 'owner.json');
|
||||
const nonce = randomUUID();
|
||||
let startTicks;
|
||||
try { startTicks = nativeProcess(process.pid).startTicks; } catch { /* Unavailable hosts remain observationally unknown. */ }
|
||||
const record = { nonce, pid: process.pid, areaId, projectId: controlPlane.projectId, startTicks, phase: 'starting' };
|
||||
writeFileSync(path, JSON.stringify(record), { flag: 'wx', mode: 0o600 });
|
||||
const setPhase = phase => {
|
||||
if (!['starting', 'working'].includes(phase)) throw new TorchError('Invalid runtime phase.', { code: 'INVALID_RUNTIME_INPUT' });
|
||||
const current = JSON.parse(readFileSync(path, 'utf8'));
|
||||
if (current.nonce !== nonce || current.pid !== process.pid) throw new TorchError('Runtime guard changed.', { code: 'RUNTIME_TURN_ACTIVE' });
|
||||
const temporary = `${path}.${nonce}.tmp`;
|
||||
writeFileSync(temporary, JSON.stringify({ ...record, phase }), { flag: 'wx', mode: 0o600 });
|
||||
renameSync(temporary, path);
|
||||
};
|
||||
try { return run(setPhase); } finally {
|
||||
// Delete only the exact guard created by this invocation; preserve foreign/changed guards.
|
||||
const record = JSON.parse(readFileSync(path, 'utf8'));
|
||||
if (record.nonce === nonce && record.pid === process.pid) {
|
||||
unlinkSync(path);
|
||||
rmdirSync(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,18 @@ import '../../site/live-refresh.js';
|
||||
|
||||
const project = (snapshot) => globalThis.TorchAttentionProjection.groups(snapshot);
|
||||
|
||||
test('SCN-console-continuation-stop: exhausted budget is an owner policy decision; no-progress remains manager-owned', () => {
|
||||
const groups = project({ continuation: { available: true, stopReason: 'daily-turn-cap', attempts: 12,
|
||||
maxTurnsPerDay: 12, day: '2026-10-03', held: [{ areaId: 'owner-console' }] } });
|
||||
assert.equal(groups.owner.length, 1);
|
||||
assert.match(groups.owner[0].detail, /explicitly change the limit/);
|
||||
assert.equal(groups.owner[0].decisionApprovalId, null, 'No fake approval or automatic budget expansion');
|
||||
assert.equal(groups.fleet.length, 1);
|
||||
assert.equal(groups.fleet[0].owner, 'session-manager');
|
||||
assert.equal(groups.fleet[0].requestOwner, 'session-manager');
|
||||
assert.equal(project({ continuation: { available: true, stopReason: 'paused', held: [] } }).owner.length, 0);
|
||||
});
|
||||
|
||||
test('SCN-console-attention-ownership: only owner-addressed pending decisions enter Waiting on you', () => {
|
||||
const groups = project({
|
||||
agents: [
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
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 { IntegrationService } from '../../src/integration/service.mjs';
|
||||
|
||||
const sha = value => execFileSync('git', ['-C', value, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
||||
const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
|
||||
function fixture(name) {
|
||||
const root = mkdtempSync(join(tmpdir(), `${name}-`));
|
||||
execFileSync('git', ['init', '-b', 'main', root]);
|
||||
execFileSync('git', ['-C', root, 'config', 'user.email', '[email protected]']);
|
||||
execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']);
|
||||
writeFileSync(join(root, 'package.json'), `${JSON.stringify({ name, scripts: {} })}\n`);
|
||||
writeFileSync(join(root, 'app.mjs'), 'export const value = 1;\n');
|
||||
execFileSync('git', ['-C', root, 'add', '.']);
|
||||
execFileSync('git', ['-C', root, 'commit', '-m', 'fixture base']);
|
||||
const repository = inspectRepository(root);
|
||||
const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) });
|
||||
proposal.domains.push({
|
||||
id: 'review-area', title: 'Review Area', kind: 'development',
|
||||
scope: ['review fixtures'], not_scope: ['application source'], owned_paths: ['review/**'],
|
||||
shared_paths: [], neighbours: [], required_checks: [], resources: [], runtime: 'claude', evidence: ['app.mjs'],
|
||||
});
|
||||
proposal.checks = [];
|
||||
proposal.review = { status: 'approved', reviewedAt: '2026-10-03T00:00:00.000Z', reviewedBy: 'fixture-owner', notes: [] };
|
||||
const env = { ...process.env, XDG_DATA_HOME: join(tmpdir(), `${basename(root)}-state`) };
|
||||
installProject({ repository, proposal, env, projectId: name });
|
||||
execFileSync('git', ['-C', root, 'add', '.torch']);
|
||||
execFileSync('git', ['-C', root, 'commit', '-m', 'install fixture control plane']);
|
||||
const base = sha(root);
|
||||
execFileSync('git', ['-C', root, 'checkout', '-b', 'candidate']);
|
||||
writeFileSync(join(root, 'candidate.mjs'), 'export const candidate = true;\n');
|
||||
execFileSync('git', ['-C', root, 'add', 'candidate.mjs']);
|
||||
execFileSync('git', ['-C', root, 'commit', '-m', 'candidate contribution']);
|
||||
const candidate = sha(root);
|
||||
execFileSync('git', ['-C', root, 'checkout', 'main']);
|
||||
const control = openControlPlane({ repositoryRoot: root, env });
|
||||
const service = new IntegrationService({ repositoryRoot: root, controlPlane: control, checkService: { exactPasses: () => false } });
|
||||
const contributor = proposal.domains[0].id;
|
||||
return { root, env, control, service, contributor, destination: 'review-area', base, candidate };
|
||||
}
|
||||
|
||||
function concurrentAdoptedRequest(context, requestInput) {
|
||||
const childSource = `
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { resolve } from 'node:path';
|
||||
const { repositoryRoot, stateRoot, requestInput } = JSON.parse(process.argv[1]);
|
||||
const sourceRoot = process.cwd();
|
||||
const { openControlPlane } = await import(pathToFileURL(resolve(sourceRoot, 'src/control-plane/service.mjs')).href);
|
||||
const { IntegrationService } = await import(pathToFileURL(resolve(sourceRoot, 'src/integration/service.mjs')).href);
|
||||
const control = openControlPlane({ repositoryRoot, env: { ...process.env, XDG_DATA_HOME: stateRoot } });
|
||||
const service = new IntegrationService({ repositoryRoot, controlPlane: control, checkService: { exactPasses: () => false } });
|
||||
try {
|
||||
const result = service.requestAdoptedCandidate(requestInput);
|
||||
console.log(JSON.stringify({ outcome: 'requested', integrationRequestId: result.integrationRequest.id }));
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify({ outcome: 'refused', code: error.code ?? null }));
|
||||
} finally {
|
||||
control.close();
|
||||
}
|
||||
`;
|
||||
const encoded = JSON.stringify({ repositoryRoot: context.root, stateRoot: context.env.XDG_DATA_HOME, requestInput });
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', childSource, encoded], {
|
||||
cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = ''; let stderr = '';
|
||||
child.stdout.on('data', chunk => { stdout += chunk; });
|
||||
child.stderr.on('data', chunk => { stderr += chunk; });
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) return reject(new Error(`concurrent adopted request child failed: ${stderr}`));
|
||||
try { resolve(JSON.parse(stdout.trim().split('\n').at(-1))); }
|
||||
catch (error) { reject(new Error(`invalid concurrent adopted request output: ${stdout}\n${stderr}\n${error.message}`)); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function authority(context, { action, adoptionId }) {
|
||||
const provenance = ['fixture:owner-approved', `candidate:${context.candidate}`];
|
||||
const binding = {
|
||||
schema: 'torch.dev/integration-adoption-authority/v1alpha1', action, adoptionId,
|
||||
candidateSha: context.candidate, baseTargetSha: context.base,
|
||||
provenanceManifestDigest: digest(provenance), contributorArea: context.contributor,
|
||||
destinationArea: context.destination, targetBranch: 'main',
|
||||
candidateRef: `refs/torch/integration-candidates/${adoptionId}`,
|
||||
expectedOldRef: 'refs/heads/main', adapter: {
|
||||
name: context.service.adoption.refAdapter.name, id: context.service.adoption.refAdapter.id,
|
||||
},
|
||||
};
|
||||
const pending = context.control.requestApproval({
|
||||
requester: context.contributor, approver: 'owner', task: 'TASK-f40126ee',
|
||||
title: `Fixture ${action} authority`, summary: 'Hermetic exact-source adoption scenario.',
|
||||
});
|
||||
const approved = context.control.decideApproval({
|
||||
approvalId: pending.id, decidedBy: 'owner', decision: 'approved', expectedRevision: pending.revision,
|
||||
note: JSON.stringify(binding),
|
||||
});
|
||||
return {
|
||||
approvalId: approved.id, approvalRevision: approved.revision, provenance,
|
||||
provenanceManifestDigest: binding.provenanceManifestDigest, adapter: binding.adapter,
|
||||
};
|
||||
}
|
||||
|
||||
function input(context, action, adoptionId, extra = {}) {
|
||||
return {
|
||||
actorId: 'owner', adoptionId, candidateSha: context.candidate, baseTargetSha: context.base,
|
||||
contributorArea: context.contributor, destinationArea: context.destination,
|
||||
targetBranch: 'main', candidateRef: `refs/torch/integration-candidates/${adoptionId}`,
|
||||
expectedOldRef: 'refs/heads/main', ...authority(context, { action, adoptionId }), ...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function prepare(context, adoptionId = 'fixture-adoption') {
|
||||
const preparedInput = input(context, 'prepare', adoptionId);
|
||||
const plan = context.service.planAdoption({ ...preparedInput, action: 'prepare' });
|
||||
assert.deepEqual(plan.blockers, [], `preparation must begin from an exact clean fixture: ${JSON.stringify(plan.blockers)}`);
|
||||
return context.service.prepareAdoption(preparedInput);
|
||||
}
|
||||
|
||||
function lifecycleInput(context, action, adoptionId, extra = {}) {
|
||||
const { approvalId, approvalRevision, adapter } = authority(context, { action, adoptionId });
|
||||
const record = context.service.getPreparedAdoption(adoptionId);
|
||||
return { actorId: 'owner', adoptionId, expectedRecordRevision: record.revision, approvalId, approvalRevision, adapter, ...extra };
|
||||
}
|
||||
|
||||
function blockedCodes(error) {
|
||||
assert.equal(error.code.endsWith('_BLOCKED'), true, `expected a blocked adoption transition, got ${error.code}`);
|
||||
return error.details.map((blocker) => blocker.code);
|
||||
}
|
||||
|
||||
function lifecycleBoundary(context, adoptionId) {
|
||||
const record = context.service.getPreparedAdoption(adoptionId);
|
||||
return {
|
||||
record,
|
||||
refs: context.service.adoption.refAdapter.readRefs([
|
||||
record.expectedOldRef, record.candidateRef, record.archiveRef,
|
||||
]),
|
||||
queue: context.service.list(),
|
||||
auditCount: context.control.database.prepare(`SELECT COUNT(*) AS count FROM audit_events
|
||||
WHERE project_id = ? AND entity_type = 'integration-adoption' AND entity_id = ?`)
|
||||
.get(context.control.projectId, adoptionId).count,
|
||||
};
|
||||
}
|
||||
|
||||
function assertLifecycleBoundaryUnchanged(context, adoptionId, before) {
|
||||
assert.deepEqual(lifecycleBoundary(context, adoptionId), before);
|
||||
}
|
||||
|
||||
test('SCN-integration-adoption-private-ref-cas: a real temporary Git transaction atomically creates private archive and candidate refs, and stale expected refs refuse', () => {
|
||||
const context = fixture('torch-adoption-cas');
|
||||
const adoptionId = 'fixture-adoption';
|
||||
prepare(context, adoptionId);
|
||||
const finalized = context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId));
|
||||
const refs = context.service.adoption.refAdapter.readRefs([
|
||||
'refs/heads/main', finalized.candidateRef, finalized.archiveRef,
|
||||
]);
|
||||
assert.deepEqual(refs, {
|
||||
'refs/heads/main': context.base,
|
||||
[finalized.candidateRef]: context.candidate,
|
||||
[finalized.archiveRef]: context.base,
|
||||
});
|
||||
assert.throws(() => context.service.adoption.refAdapter.compareAndSwapRefs({
|
||||
expected: [{ ref: finalized.candidateRef, sha: context.base }],
|
||||
writes: [{ ref: finalized.candidateRef, sha: null }],
|
||||
}), (error) => error.code === 'ADOPTION_REF_CAS_FAILED');
|
||||
assert.equal(context.service.adoption.getPrepared(adoptionId).state, 'finalized');
|
||||
context.control.close();
|
||||
});
|
||||
|
||||
test('SCN-integration-adoption-journal-and-binding: a duplicate preparation cannot overwrite the journal and lifecycle input cannot alter its immutable candidate binding', () => {
|
||||
const context = fixture('torch-adoption-journal');
|
||||
const adoptionId = 'fixture-adoption';
|
||||
const prepared = prepare(context, adoptionId);
|
||||
assert.throws(() => context.service.prepareAdoption(input(context, 'prepare', adoptionId)),
|
||||
(error) => error.code === 'ADOPTION_RECORD_REVISION_STALE');
|
||||
assert.throws(() => context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId, { candidateSha: context.base })),
|
||||
(error) => error.code === 'ADOPTION_IMMUTABLE_BINDING_STALE');
|
||||
assert.throws(() => context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId, {
|
||||
expectedRecordRevision: prepared.revision + 1,
|
||||
})), (error) => error.code === 'ADOPTION_RECORD_REVISION_STALE');
|
||||
const refs = context.service.adoption.refAdapter.readRefs([
|
||||
prepared.expectedOldRef, prepared.candidateRef, prepared.archiveRef,
|
||||
]);
|
||||
assert.deepEqual(refs, {
|
||||
[prepared.expectedOldRef]: context.base,
|
||||
[prepared.candidateRef]: null,
|
||||
[prepared.archiveRef]: null,
|
||||
});
|
||||
const { mutationPerformed, ...durablePrepared } = prepared;
|
||||
assert.equal(mutationPerformed, true);
|
||||
assert.deepEqual(context.service.adoption.getPrepared(adoptionId), durablePrepared);
|
||||
context.control.close();
|
||||
});
|
||||
|
||||
test('SCN-integration-adoption-lifecycle-revision-cas: stale or omitted revisions for every lifecycle action refuse before refs, journal, queue, or audit effects', () => {
|
||||
const cases = [
|
||||
{
|
||||
action: 'finalize',
|
||||
setup: (context, adoptionId) => prepare(context, adoptionId),
|
||||
invoke: (context, input) => context.service.finalizeAdoption(input),
|
||||
},
|
||||
{
|
||||
action: 'reconcile',
|
||||
setup: (context, adoptionId) => prepare(context, adoptionId),
|
||||
invoke: (context, input) => context.service.reconcileAdoption(input),
|
||||
},
|
||||
{
|
||||
action: 'rollback',
|
||||
setup: (context, adoptionId) => {
|
||||
prepare(context, adoptionId);
|
||||
return context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId));
|
||||
},
|
||||
invoke: (context, input) => context.service.rollbackAdoption(input),
|
||||
},
|
||||
{
|
||||
action: 'request',
|
||||
setup: (context, adoptionId) => {
|
||||
prepare(context, adoptionId);
|
||||
return context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId));
|
||||
},
|
||||
invoke: (context, input) => context.service.requestAdoptedCandidate(input),
|
||||
},
|
||||
];
|
||||
|
||||
for (const { action, setup, invoke } of cases) {
|
||||
const context = fixture(`torch-adoption-${action}-revision-boundary`);
|
||||
const adoptionId = 'fixture-adoption';
|
||||
setup(context, adoptionId);
|
||||
for (const variant of ['omitted', 'stale']) {
|
||||
const current = lifecycleInput(context, action, adoptionId);
|
||||
const input = variant === 'omitted'
|
||||
? (({ expectedRecordRevision: _expectedRecordRevision, ...withoutRevision }) => withoutRevision)(current)
|
||||
: { ...current, expectedRecordRevision: current.expectedRecordRevision + 1 };
|
||||
const before = lifecycleBoundary(context, adoptionId);
|
||||
assert.throws(() => invoke(context, input), (error) => error.code === 'ADOPTION_RECORD_REVISION_STALE',
|
||||
`${action} must reject a ${variant} lifecycle revision`);
|
||||
assertLifecycleBoundaryUnchanged(context, adoptionId, before);
|
||||
}
|
||||
context.control.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('SCN-integration-adoption-finalize-recovery: a crash after real ref CAS but before the journal transition reconciles once and never duplicates refs', () => {
|
||||
const context = fixture('torch-adoption-finalize-recovery');
|
||||
const adoptionId = 'fixture-adoption';
|
||||
const prepared = prepare(context, adoptionId);
|
||||
context.service.adoption.refAdapter.compareAndSwapRefs({
|
||||
expected: [
|
||||
{ ref: prepared.expectedOldRef, sha: prepared.baseTargetSha },
|
||||
{ ref: prepared.candidateRef, sha: null },
|
||||
{ ref: prepared.archiveRef, sha: null },
|
||||
],
|
||||
writes: [
|
||||
{ ref: prepared.archiveRef, sha: prepared.baseTargetSha },
|
||||
{ ref: prepared.candidateRef, sha: prepared.candidateSha },
|
||||
],
|
||||
});
|
||||
const recovered = context.service.reconcileAdoption(lifecycleInput(context, 'reconcile', adoptionId));
|
||||
assert.equal(recovered.state, 'recovered-finalized');
|
||||
assert.equal(recovered.record.recovery, 'private-ref-cas-completed-before-journal');
|
||||
const replay = context.service.reconcileAdoption(lifecycleInput(context, 'reconcile', adoptionId));
|
||||
assert.equal(replay.state, 'consistent');
|
||||
assert.equal(replay.mutationPerformed, false);
|
||||
context.control.close();
|
||||
});
|
||||
|
||||
test('SCN-integration-adoption-rollback-recovery: a journal-missing rollback completes without a second deletion, while divergent candidate bytes refuse', () => {
|
||||
const context = fixture('torch-adoption-rollback-recovery');
|
||||
const adoptionId = 'fixture-adoption';
|
||||
prepare(context, adoptionId);
|
||||
const finalized = context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId));
|
||||
assert.equal(finalized.state, 'finalized');
|
||||
context.service.adoption.refAdapter.compareAndSwapRefs({
|
||||
expected: [{ ref: finalized.candidateRef, sha: finalized.candidateSha }],
|
||||
writes: [{ ref: finalized.candidateRef, sha: null }],
|
||||
});
|
||||
const recovered = context.service.rollbackAdoption(lifecycleInput(context, 'rollback', adoptionId));
|
||||
assert.equal(recovered.state, 'rolled-back');
|
||||
assert.equal(recovered.recovery, 'private-ref-rollback-completed-before-journal');
|
||||
|
||||
const divergent = fixture('torch-adoption-rollback-divergence');
|
||||
const divergentId = 'fixture-adoption';
|
||||
prepare(divergent, divergentId);
|
||||
const divergentFinal = divergent.service.finalizeAdoption(lifecycleInput(divergent, 'finalize', divergentId));
|
||||
divergent.service.adoption.refAdapter.compareAndSwapRefs({
|
||||
expected: [{ ref: divergentFinal.candidateRef, sha: divergentFinal.candidateSha }],
|
||||
writes: [{ ref: divergentFinal.candidateRef, sha: divergent.base }],
|
||||
});
|
||||
assert.throws(() => divergent.service.rollbackAdoption(lifecycleInput(divergent, 'rollback', divergentId)),
|
||||
(error) => error.code === 'ADOPTION_ROLLBACK_REF_DIVERGED');
|
||||
context.control.close();
|
||||
divergent.control.close();
|
||||
});
|
||||
|
||||
test('SCN-integration-adoption-private-ref-intake: finalized private refs enter the ordinary native integration queue, not a specialist branch shortcut', () => {
|
||||
const unfinalized = fixture('torch-adoption-unfinalized-intake');
|
||||
const unfinalizedId = 'fixture-adoption';
|
||||
const unfinalizedRef = `refs/torch/integration-candidates/${unfinalizedId}`;
|
||||
unfinalized.service.adoption.refAdapter.compareAndSwapRefs({
|
||||
expected: [{ ref: unfinalizedRef, sha: null }],
|
||||
writes: [{ ref: unfinalizedRef, sha: unfinalized.candidate }],
|
||||
});
|
||||
assert.throws(() => unfinalized.service.requestCandidate({
|
||||
areaId: unfinalized.contributor, commit: unfinalized.candidate, candidateRef: unfinalizedRef,
|
||||
}), (error) => error.code === 'INTEGRATION_ADOPTED_INTAKE_INTERNAL_ONLY');
|
||||
assert.deepEqual(unfinalized.service.list(), []);
|
||||
unfinalized.control.close();
|
||||
|
||||
const context = fixture('torch-adoption-native-intake');
|
||||
const adoptionId = 'fixture-adoption';
|
||||
prepare(context, adoptionId);
|
||||
const finalized = context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId));
|
||||
assert.throws(() => context.service.requestAdoptedCandidate(lifecycleInput(context, 'request', adoptionId, {
|
||||
expectedRecordRevision: finalized.revision + 1,
|
||||
})), (error) => error.code === 'ADOPTION_RECORD_REVISION_STALE');
|
||||
assert.deepEqual(context.service.list(), []);
|
||||
const requested = context.service.requestAdoptedCandidate(lifecycleInput(context, 'request', adoptionId));
|
||||
assert.equal(requested.state, 'requested');
|
||||
assert.equal(requested.integrationRequest.sourceBranch, finalized.candidateRef);
|
||||
assert.equal(requested.integrationRequest.sourceCommit, context.candidate);
|
||||
assert.equal(requested.integrationRequest.state, 'testing');
|
||||
assert.equal(requested.integrationRequest.targetBranch, 'main');
|
||||
context.control.close();
|
||||
});
|
||||
|
||||
test('SCN-integration-adoption-approval-boundaries: unrelated or source-only approval records cannot authorize an owner adoption, and actor, action, revision, and provenance mismatches each refuse', () => {
|
||||
const unrelated = fixture('torch-adoption-unrelated-approval');
|
||||
const unrelatedId = 'fixture-adoption';
|
||||
const unrelatedApproval = unrelated.control.requestApproval({
|
||||
requester: unrelated.contributor, approver: 'owner', task: 'TASK-unrelated',
|
||||
title: 'Ordinary source branch review', summary: 'Not an integration-adoption authority.',
|
||||
});
|
||||
const unrelatedApproved = unrelated.control.decideApproval({
|
||||
approvalId: unrelatedApproval.id, decidedBy: 'owner', decision: 'approved', expectedRevision: unrelatedApproval.revision,
|
||||
note: JSON.stringify({ schema: 'torch.dev/ordinary-source-review/v1alpha1', sourceArea: unrelated.contributor }),
|
||||
});
|
||||
const unrelatedInput = {
|
||||
actorId: 'owner', adoptionId: unrelatedId, candidateSha: unrelated.candidate, baseTargetSha: unrelated.base,
|
||||
contributorArea: unrelated.contributor, destinationArea: unrelated.destination, targetBranch: 'main',
|
||||
candidateRef: `refs/torch/integration-candidates/${unrelatedId}`, expectedOldRef: 'refs/heads/main',
|
||||
approvalId: unrelatedApproved.id, approvalRevision: unrelatedApproved.revision,
|
||||
provenance: ['fixture:owner-approved', `candidate:${unrelated.candidate}`],
|
||||
provenanceManifestDigest: digest(['fixture:owner-approved', `candidate:${unrelated.candidate}`]),
|
||||
adapter: { name: unrelated.service.adoption.refAdapter.name, id: unrelated.service.adoption.refAdapter.id },
|
||||
};
|
||||
assert.throws(() => unrelated.service.prepareAdoption(unrelatedInput), (error) => blockedCodes(error).includes('ADOPTION_APPROVAL_BINDING_INVALID'));
|
||||
assert.deepEqual(unrelated.service.list(), []);
|
||||
unrelated.control.close();
|
||||
|
||||
const wrongActor = fixture('torch-adoption-wrong-actor');
|
||||
assert.throws(() => wrongActor.service.prepareAdoption({
|
||||
...input(wrongActor, 'prepare', 'fixture-adoption'), actorId: wrongActor.contributor,
|
||||
}), (error) => blockedCodes(error).includes('OWNER_ACTOR_REQUIRED'));
|
||||
wrongActor.control.close();
|
||||
|
||||
const wrongAction = fixture('torch-adoption-wrong-action');
|
||||
prepare(wrongAction, 'fixture-adoption');
|
||||
assert.throws(() => wrongAction.service.finalizeAdoption(lifecycleInput(wrongAction, 'rollback', 'fixture-adoption')),
|
||||
(error) => blockedCodes(error).includes('ADOPTION_APPROVAL_BINDING_INVALID'));
|
||||
wrongAction.control.close();
|
||||
|
||||
const staleRevision = fixture('torch-adoption-stale-revision');
|
||||
prepare(staleRevision, 'fixture-adoption');
|
||||
const staleInput = lifecycleInput(staleRevision, 'finalize', 'fixture-adoption');
|
||||
assert.throws(() => staleRevision.service.finalizeAdoption({ ...staleInput, approvalRevision: staleInput.approvalRevision - 1 }),
|
||||
(error) => blockedCodes(error).includes('ADOPTION_APPROVAL_REVISION_STALE'));
|
||||
staleRevision.control.close();
|
||||
|
||||
const wrongProvenance = fixture('torch-adoption-wrong-provenance');
|
||||
assert.throws(() => wrongProvenance.service.prepareAdoption({
|
||||
...input(wrongProvenance, 'prepare', 'fixture-adoption'), provenance: ['fixture:owner-approved', 'unapproved:alternate-provenance'],
|
||||
}), (error) => blockedCodes(error).includes('ADOPTION_PROVENANCE_DIGEST_MISMATCH'));
|
||||
wrongProvenance.control.close();
|
||||
});
|
||||
|
||||
test('SCN-integration-adoption-request-replay: concurrent authenticated API callers serialize on the adoption journal and create exactly one private-ref queue request', async () => {
|
||||
const context = fixture('torch-adoption-request-replay');
|
||||
const adoptionId = 'fixture-adoption';
|
||||
prepare(context, adoptionId);
|
||||
context.service.finalizeAdoption(lifecycleInput(context, 'finalize', adoptionId));
|
||||
const requestInput = lifecycleInput(context, 'request', adoptionId);
|
||||
const outcomes = await Promise.all([
|
||||
concurrentAdoptedRequest(context, requestInput),
|
||||
concurrentAdoptedRequest(context, requestInput),
|
||||
]);
|
||||
assert.deepEqual(outcomes.map((entry) => entry.outcome).sort(), ['refused', 'requested']);
|
||||
assert.equal(outcomes.find((entry) => entry.outcome === 'refused').code, 'ADOPTION_RECORD_REVISION_STALE');
|
||||
const requests = context.service.list();
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(context.service.getPreparedAdoption(adoptionId).state, 'requested');
|
||||
context.control.close();
|
||||
});
|
||||
|
||||
test('SCN-integration-adoption-legacy-retries: historical ordinary-request retries remain readable when integration service starts', () => {
|
||||
const context = fixture('torch-adoption-legacy-retries');
|
||||
const insert = context.control.database.prepare(`INSERT INTO integration_requests (
|
||||
id, project_id, source_area, source_branch, source_commit, target_branch,
|
||||
base_target_commit, state, reason, required_checks, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
for (const [id, state] of [['historical-failed', 'blocked'], ['historical-retry', 'superseded']]) {
|
||||
insert.run(id, context.control.projectId, context.contributor, 'refs/heads/candidate', context.candidate,
|
||||
'main', context.base, state, 'historical-retry', '[]', '2026-10-03T00:00:00.000Z', '2026-10-03T00:00:00.000Z');
|
||||
}
|
||||
assert.doesNotThrow(() => new IntegrationService({
|
||||
repositoryRoot: context.root, controlPlane: context.control, checkService: { exactPasses: () => false },
|
||||
}));
|
||||
assert.equal(context.service.list().length, 2);
|
||||
context.control.close();
|
||||
});
|
||||
@@ -0,0 +1,433 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, writeFileSync } 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 { inspectRepository } from '../../src/kernel/git.mjs';
|
||||
import { proposeDomains } from '../../src/kernel/domains.mjs';
|
||||
import { installProject } from '../../src/kernel/install.mjs';
|
||||
import { openControlPlane } from '../../src/control-plane/service.mjs';
|
||||
import { issueStoppedExecutorEvidence } from '../../src/runtime/stopped-executor-evidence.mjs';
|
||||
import { observeRuntimeTurn, withRuntimeTurnGuard } from '../../src/runtime/turn-guard.mjs';
|
||||
import { observeProject } from '../../src/observability/snapshot.mjs';
|
||||
import { configureContinuation, observeContinuation, planContinuation, runContinuation } from '../../src/runtime/continuation.mjs';
|
||||
|
||||
function fixture({ recoveryWorktree = true, extraWorker = false, workerCount = 0 } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'torch-legacy-recovery-'));
|
||||
for (const args of [['init', '-b', 'main'], ['config', 'user.email', '[email protected]'],
|
||||
['config', 'user.name', 'Recovery Fixture']]) execFileSync('git', args, { cwd: root, stdio: 'pipe' });
|
||||
writeFileSync(join(root, 'app.js'), 'export const ready = true;\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: root });
|
||||
execFileSync('git', ['commit', '-m', 'fixture'], { cwd: root, stdio: 'pipe' });
|
||||
const repository = inspectRepository(root);
|
||||
const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) });
|
||||
if (extraWorker) proposal.domains.push({ ...proposal.domains[0], id: 'extra-worker', title: 'Extra worker', owned_paths: ['extra/**'] });
|
||||
for (let i = 0; i < workerCount; i++) proposal.domains.push({ ...proposal.domains[0], id: `worker-${i}`, title: `Worker ${i}`, owned_paths: [`worker-${i}/**`] });
|
||||
proposal.review = { status: 'approved', reviewedAt: '2026-10-03T00:00:00Z', reviewedBy: 'owner', notes: [] };
|
||||
const env = { ...process.env, XDG_DATA_HOME: join(root, '.data') };
|
||||
installProject({ repository, proposal, env, projectId: 'recovery-fixture' });
|
||||
const actualPath = join(root, '.torch', 'install-manifest.json');
|
||||
const manifest = JSON.parse(readFileSync(actualPath, 'utf8'));
|
||||
const areaId = proposal.domains[0].id;
|
||||
if (recoveryWorktree) manifest.external.push({ type: 'worktree', area: areaId, path: join(root, 'worker') });
|
||||
writeFileSync(actualPath, JSON.stringify(manifest));
|
||||
let time = Date.parse('2026-10-03T00:00:00Z');
|
||||
const control = openControlPlane({ repositoryRoot: root, env, clock: () => new Date(time) });
|
||||
control.reportStatus({ areaId, state: 'working', runtime: 'codex', runtimeSessionId: 'fixture-session',
|
||||
task: 'TASK-preserved', summary: 'Runtime executor completion is unknown; duplicate launch is blocked pending reconciliation.' });
|
||||
const identity = control.getAgent(areaId);
|
||||
const unit = `torch-${control.projectId}-worker.service`;
|
||||
const properties = { Id: unit, ActiveState: 'failed', SubState: 'failed', MainPID: '0', ControlPID: '0',
|
||||
ControlGroup: '', InvocationID: 'a'.repeat(32), ExecStart: `node /tmp/probe.mjs ${areaId} ;`,
|
||||
WorkingDirectory: root, ExecMainExitTimestamp: identity.updatedAt };
|
||||
let processes = [];
|
||||
const issue = () => issueStoppedExecutorEvidence({ controlPlane: control, actorId: 'owner', areaId, unit,
|
||||
invocationId: 'a'.repeat(32), readUnit: () => ({ ...properties }), readProcesses: () => processes, now: () => time });
|
||||
const apply = issued => control.recoverOwnerStoppedUnknownIdentity({ actorId: 'owner', areaId,
|
||||
expectedIdentity: issued.plan.expectedIdentity, evidence: issued.evidence });
|
||||
return { root, env, control, identity, areaId, properties, issue, apply,
|
||||
advance: ms => { time += ms; }, processes: value => { processes = value; } };
|
||||
}
|
||||
|
||||
test('SCN-legacy-runtime-recovery: owner recovery preserves task/session/unknown and records exactly one owner audit', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const issued = f.issue();
|
||||
assert.equal(f.control.getAgent(f.areaId).state, 'working');
|
||||
const result = f.apply(issued);
|
||||
assert.equal(result.identity.state, 'offline');
|
||||
assert.equal(result.identity.currentTask, 'TASK-preserved');
|
||||
assert.equal(result.identity.runtimeSessionId, 'fixture-session');
|
||||
assert.equal(result.priorOutcome, 'unknown');
|
||||
assert.equal(result.sessionsStarted, false);
|
||||
const audits = f.control.readAudit({ limit: 100 }).filter(x => x.operation === 'runtime.legacy-unknown.recover-stopped');
|
||||
assert.equal(audits.length, 1);
|
||||
assert.equal(audits[0].actorId, 'owner');
|
||||
assert.equal(audits[0].details.outcome, 'unknown');
|
||||
assert.throws(() => f.apply(issued), { code: 'RUNTIME_RECOVERY_SNAPSHOT_CONFLICT' });
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-legacy-runtime-recovery-refusal: live, foreign invocation, area, time and process mismatches cannot issue proof', () => {
|
||||
for (const patch of [{ MainPID: '99' }, { ControlPID: '99' }, { ActiveState: 'active' },
|
||||
{ InvocationID: 'b'.repeat(32) }, { ExecStart: 'node other-area' },
|
||||
{ ExecMainExitTimestamp: '2026-10-02T00:00:00Z' }, { ControlGroup: '/live' }]) {
|
||||
const f = fixture();
|
||||
try { Object.assign(f.properties, patch); assert.throws(f.issue, { code: 'RUNTIME_RECOVERY_UNVERIFIED' });
|
||||
assert.equal(f.control.getAgent(f.areaId).state, 'working'); } finally { f.control.close(); }
|
||||
}
|
||||
const f = fixture();
|
||||
try { f.processes([{ pid: 999, argv: ['codex', 'resume', 'fixture-session'], cwd: '/tmp' }]);
|
||||
assert.throws(f.issue, { code: 'RUNTIME_RECOVERY_UNVERIFIED' }); } finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-legacy-runtime-recovery-cas: changed snapshot, live recheck, expiry and caller-forged proof refuse without mutation', () => {
|
||||
for (const scenario of ['changed', 'live', 'expired', 'forged']) {
|
||||
const f = fixture();
|
||||
try {
|
||||
const issued = f.issue();
|
||||
if (scenario === 'changed') { f.advance(1); f.control.reportStatus({ areaId: f.areaId, state: 'working', runtimeSessionId: 'different', summary: 'Runtime executor completion is unknown' }); }
|
||||
if (scenario === 'live') f.properties.MainPID = '99';
|
||||
if (scenario === 'expired') f.advance(60_001);
|
||||
if (scenario === 'forged') issued.evidence = { stopped: true };
|
||||
assert.throws(() => f.apply(issued));
|
||||
assert.equal(f.control.getAgent(f.areaId).state, 'working');
|
||||
assert.equal(f.control.readAudit({ limit: 100 }).filter(x => x.operation === 'runtime.legacy-unknown.recover-stopped').length, 0);
|
||||
} finally { f.control.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('SCN-legacy-runtime-recovery-authority: specialist and incomplete snapshot cannot use owner recovery', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const issued = f.issue();
|
||||
assert.throws(() => f.control.recoverOwnerStoppedUnknownIdentity({ actorId: f.areaId, areaId: f.areaId,
|
||||
expectedIdentity: issued.plan.expectedIdentity, evidence: issued.evidence }), { code: 'OWNER_AUTHORITY_REQUIRED' });
|
||||
assert.throws(() => f.control.recoverOwnerStoppedUnknownIdentity({ actorId: 'owner', areaId: f.areaId,
|
||||
expectedIdentity: {}, evidence: issued.evidence }), { code: 'RUNTIME_RECOVERY_SNAPSHOT_REQUIRED' });
|
||||
assert.equal(f.control.getAgent(f.areaId).state, 'working');
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-runtime-turn-guard: overlapping executors refuse even if an agent reports idle; terminal turns release their own guard', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
let launches = 0;
|
||||
withRuntimeTurnGuard(f.control, f.areaId, () => {
|
||||
launches++;
|
||||
f.control.reportStatus({ areaId: f.areaId, state: 'idle', summary: 'Agent reported idle before physical exit' });
|
||||
assert.throws(() => withRuntimeTurnGuard(f.control, f.areaId, () => { launches++; }), { code: 'RUNTIME_TURN_ACTIVE' });
|
||||
});
|
||||
withRuntimeTurnGuard(f.control, f.areaId, () => { launches++; });
|
||||
assert.equal(launches, 2);
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-runtime-live-presence: live guard phases override stale idle display without changing durable reports or waits', { skip: process.platform !== 'linux' }, () => {
|
||||
const f = fixture({ recoveryWorktree: false });
|
||||
try {
|
||||
f.control.reportStatus({ areaId: f.areaId, state: 'idle', summary: 'Previous turn completed' });
|
||||
const observe = () => observeProject({ repositoryRoot: f.root, env: f.env }).agents.find(a => a.areaId === f.areaId);
|
||||
withRuntimeTurnGuard(f.control, f.areaId, phase => {
|
||||
let agent = observe();
|
||||
assert.equal(agent.state, 'starting');
|
||||
assert.equal(agent.reportedState, 'idle');
|
||||
assert.equal(agent.executor.source, 'live-process-and-turn-guard');
|
||||
phase('working');
|
||||
agent = observe();
|
||||
assert.equal(agent.state, 'working');
|
||||
assert.equal(f.control.getAgent(f.areaId).state, 'idle', 'Observation is not an identity mutation');
|
||||
f.control.reportStatus({ areaId: f.areaId, state: 'waiting', summary: 'Waiting for named approval' });
|
||||
assert.equal(observe().state, 'waiting', 'Live execution must not conceal semantic approval waits');
|
||||
});
|
||||
assert.equal(observe().executor.state, 'inactive');
|
||||
assert.equal(observe().state, 'waiting');
|
||||
f.control.reportStatus({ areaId: f.areaId, state: 'idle', summary: 'Turn completed' });
|
||||
assert.equal(observe().state, 'idle');
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-runtime-live-presence-refusal: dead, replaced, foreign-user and malformed guards never claim active execution', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
withRuntimeTurnGuard(f.control, f.areaId, () => {
|
||||
const path = join(f.control.stateRoot, 'sessions', 'turn-guards', f.areaId, 'owner.json');
|
||||
const original = JSON.parse(readFileSync(path, 'utf8'));
|
||||
writeFileSync(path, JSON.stringify({ ...original, startTicks: 'fixture-start' }));
|
||||
const read = live => observeRuntimeTurn({ stateRoot: f.control.stateRoot, repositoryRoot: f.root,
|
||||
projectId: f.control.projectId, areaId: f.areaId, uid: 1000, inspectProcess: () => live });
|
||||
const live = { uid: 1000, startTicks: 'fixture-start', state: 'S' };
|
||||
assert.equal(read(live).state, 'active');
|
||||
for (const mismatch of [{ ...live, uid: 2000 }, { ...live, startTicks: 'reused-pid' }, { ...live, state: 'Z' }]) {
|
||||
assert.equal(read(mismatch).state, 'unknown');
|
||||
}
|
||||
const absent = observeRuntimeTurn({ stateRoot: f.control.stateRoot, repositoryRoot: f.root,
|
||||
projectId: f.control.projectId, areaId: f.areaId, inspectProcess: () => { throw new Error('Process gone'); } });
|
||||
assert.equal(absent.state, 'unknown');
|
||||
writeFileSync(path, JSON.stringify({ ...original, projectId: 'foreign' }));
|
||||
assert.equal(read(live).reason, 'guard-invalid');
|
||||
writeFileSync(path, JSON.stringify(original));
|
||||
});
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-runtime-live-presence-legacy: pre-observation guards require exact TORCH identity and project cwd', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
withRuntimeTurnGuard(f.control, f.areaId, () => {
|
||||
const path = join(f.control.stateRoot, 'sessions', 'turn-guards', f.areaId, 'owner.json');
|
||||
const original = JSON.parse(readFileSync(path, 'utf8'));
|
||||
const legacy = { ...original };
|
||||
delete legacy.startTicks;
|
||||
delete legacy.phase;
|
||||
writeFileSync(path, JSON.stringify(legacy));
|
||||
const inspect = live => observeRuntimeTurn({ stateRoot: f.control.stateRoot, repositoryRoot: f.root,
|
||||
projectId: f.control.projectId, areaId: f.areaId, uid: 1000, inspectProcess: () => live });
|
||||
const live = { uid: 1000, state: 'S', cwd: f.root, argv: ['node', '/runtime/bin/torch.mjs', 'up', '--only', f.areaId] };
|
||||
assert.equal(inspect(live).phase, 'working');
|
||||
assert.equal(inspect({ ...live, cwd: '/foreign' }).state, 'unknown');
|
||||
assert.equal(inspect({ ...live, argv: ['node', 'torch.mjs', 'up', '--only', 'someone-else'] }).state, 'unknown');
|
||||
writeFileSync(path, JSON.stringify(original));
|
||||
});
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-runtime-continuation: explicit enable, event deduplication, daily cap and pause govern authorized turns', async () => {
|
||||
const f = fixture();
|
||||
const backlog = { list: () => [] };
|
||||
const at = new Date('2026-10-03T00:00:00Z');
|
||||
try {
|
||||
f.control.reportStatus({ areaId: f.areaId, state: 'idle', summary: 'Physical turn completed' });
|
||||
f.control.sendOwnerRequest({ actorId: 'owner', recipient: f.areaId, body: 'Continue assigned work' });
|
||||
assert.equal(planContinuation(f.control, backlog, { at }).reason, 'paused');
|
||||
assert.throws(() => configureContinuation(f.control, { actorId: f.areaId, enabled: true }), { code: 'OWNER_AUTHORITY_REQUIRED' });
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: true, maxTurnsPerDay: 1 });
|
||||
assert.equal(planContinuation(f.control, backlog, { at }).candidates.length, 1);
|
||||
let calls = 0;
|
||||
const run = () => runContinuation(f.control, backlog, { actorId: 'owner', at, launch: async area => { assert.equal(area, f.areaId); calls++; return 0; } });
|
||||
assert.equal((await run()).launched.length, 1);
|
||||
assert.equal((await run()).launched.length, 0);
|
||||
f.control.sendOwnerRequest({ actorId: 'owner', recipient: f.areaId, body: 'New work' });
|
||||
assert.equal(planContinuation(f.control, backlog, { at }).reason, 'daily-turn-cap');
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: true, maxTurnsPerDay: 2 });
|
||||
assert.equal(planContinuation(f.control, backlog, { at }).candidates.length, 1);
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: false, maxTurnsPerDay: 2 });
|
||||
assert.equal((await run()).launched.length, 0);
|
||||
assert.equal(calls, 1);
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-runtime-continuation-day-override: owner extra allowance preserves charges and expires at UTC reset', async () => {
|
||||
const f = fixture();
|
||||
const backlog = { list: () => [] };
|
||||
const at = new Date('2026-10-03T00:00:00Z');
|
||||
try {
|
||||
f.control.reportStatus({ areaId: f.areaId, state: 'idle', summary: 'Ready' });
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: true, maxTurnsPerDay: 1 });
|
||||
f.control.sendOwnerRequest({ actorId: 'owner', recipient: f.areaId, body: 'First work' });
|
||||
const run = () => runContinuation(f.control, backlog, { actorId: 'owner', at, launch: async () => 0 });
|
||||
assert.equal((await run()).launched.length, 1);
|
||||
assert.equal(planContinuation(f.control, backlog, { at }).reason, 'daily-turn-cap');
|
||||
assert.throws(() => configureContinuation(f.control, { actorId: f.areaId, enabled: true, todayMaxTurns: 3 }), { code: 'OWNER_AUTHORITY_REQUIRED' });
|
||||
for (const todayMaxTurns of [0, 97, 1.5]) assert.throws(() => configureContinuation(f.control,
|
||||
{ actorId: 'owner', enabled: true, todayMaxTurns }), { code: 'CONTINUATION_POLICY_INVALID' });
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: true, todayMaxTurns: 3 });
|
||||
assert.equal(planContinuation(f.control, backlog, { at }).attempts, 1, 'Previously charged turn is retained');
|
||||
assert.equal(planContinuation(f.control, backlog, { at }).remainingTurns, 2);
|
||||
f.control.sendOwnerRequest({ actorId: 'owner', recipient: f.areaId, body: 'Additional work' });
|
||||
assert.equal((await run()).launched.length, 1);
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: false });
|
||||
const sameDay = observeContinuation({ stateRoot: f.control.stateRoot, now: () => at });
|
||||
assert.equal(sameDay.attempts, 2);
|
||||
assert.equal(sameDay.maxTurnsPerDay, 3);
|
||||
assert.equal(sameDay.stopReason, 'paused');
|
||||
const nextDay = observeContinuation({ stateRoot: f.control.stateRoot, now: () => new Date('2026-10-04T00:00:00Z') });
|
||||
assert.equal(nextDay.maxTurnsPerDay, 1, 'Override cannot widen future daily allowances');
|
||||
assert.equal(nextDay.attempts, 0);
|
||||
assert.ok(f.control.readAudit({ limit: 100 }).some(a => a.operation === 'runtime.continuation.configure'
|
||||
&& a.details?.dailyOverride?.maxTurns === 3));
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-runtime-continuation-concurrency: seven authorized starts preserve budget and obey tighter limits', async () => {
|
||||
const f = fixture({ workerCount: 5 });
|
||||
const backlog = { list: () => [] };
|
||||
const at = new Date('2026-10-03T00:00:00Z');
|
||||
try {
|
||||
for (const agent of f.control.listAgents()) {
|
||||
f.control.reportStatus({ areaId: agent.areaId, state: 'idle', summary: 'Ready' });
|
||||
f.control.sendOwnerRequest({ actorId: 'owner', recipient: agent.areaId, body: 'Review work' });
|
||||
}
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: true, maxTurnsPerDay: 48 });
|
||||
assert.equal(planContinuation(f.control, backlog, { at }).maxConcurrency, 3);
|
||||
assert.equal(planContinuation(f.control, backlog, { at, limit: 7 }).candidates.length, 3, 'Tick cannot exceed approved capacity');
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: true, maxConcurrency: 7 });
|
||||
assert.equal(observeContinuation({ stateRoot: f.control.stateRoot }).maxTurnsPerDay, 48);
|
||||
assert.equal(planContinuation(f.control, backlog, { at, limit: 2 }).candidates.length, 2);
|
||||
for (const value of [0, 8, 1.5]) assert.throws(() => configureContinuation(f.control,
|
||||
{ actorId: 'owner', enabled: true, maxConcurrency: value }), { code: 'CONTINUATION_POLICY_INVALID' });
|
||||
const completions = [];
|
||||
const running = runContinuation(f.control, backlog, { actorId: 'owner', at,
|
||||
launch: () => new Promise(resolve => completions.push(resolve)) });
|
||||
assert.equal(completions.length, 7, 'All seven start before any finishes');
|
||||
assert.equal(planContinuation(f.control, backlog, { at }).attempts, 7);
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: false });
|
||||
assert.equal(observeContinuation({ stateRoot: f.control.stateRoot, now: () => at }).attempts, 7);
|
||||
assert.equal(observeContinuation({ stateRoot: f.control.stateRoot }).maxConcurrency, 7);
|
||||
completions.forEach(resolve => resolve(0));
|
||||
assert.equal((await running).launched.length, 7);
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-runtime-continuation-signals: acknowledgements do not wake; eligible turns start concurrently within the limit', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.control.reportStatus({ areaId: f.areaId, state: 'idle', summary: 'Idle' });
|
||||
const messages = [];
|
||||
for (const recipient of [f.areaId, 'session-manager']) {
|
||||
messages.push(f.control.sendOwnerRequest({ actorId: 'owner', recipient, body: 'Review current work' }));
|
||||
f.control.sendOwnerRequest({ actorId: 'owner', recipient, body: 'Second handoff' });
|
||||
}
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: true, maxTurnsPerDay: 4 });
|
||||
const completions = [];
|
||||
const running = runContinuation(f.control, { list: () => [] }, { actorId: 'owner', limit: 2,
|
||||
launch: () => new Promise(resolve => completions.push(resolve)) });
|
||||
assert.equal(completions.length, 2, 'Both starts occur without waiting for the first provider to finish');
|
||||
assert.equal((await runContinuation(f.control, { list: () => [] }, { actorId: 'owner', launch: () => 0 })).reason,
|
||||
'controller-active-or-unreconciled');
|
||||
completions.forEach(resolve => resolve(0));
|
||||
assert.equal((await running).launched.length, 2);
|
||||
for (const message of messages) f.control.ackMessage({ recipient: message.recipient, messageId: message.id });
|
||||
assert.equal(planContinuation(f.control, { list: () => [] }).candidates.length, 0);
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-runtime-continuation-pause: a pause during one turn prevents another queued turn', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.control.reportStatus({ areaId: f.areaId, state: 'idle', summary: 'Idle' });
|
||||
for (const recipient of [f.areaId, 'session-manager']) f.control.sendOwnerRequest({ actorId: 'owner', recipient, body: 'Review current work' });
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: true, maxTurnsPerDay: 3 });
|
||||
const result = await runContinuation(f.control, { list: () => [] }, { actorId: 'owner',
|
||||
launch: async () => { configureContinuation(f.control, { actorId: 'owner', enabled: false, maxTurnsPerDay: 3 }); return 0; } });
|
||||
assert.equal(result.launched.length, 1);
|
||||
assert.equal(planContinuation(f.control, { list: () => [] }).reason, 'paused');
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-runtime-continuation-refill: a short turn frees capacity while the manager remains running', async () => {
|
||||
const f = fixture({ extraWorker: true });
|
||||
try {
|
||||
f.control.reportStatus({ areaId: f.areaId, state: 'idle', summary: 'Idle' });
|
||||
for (const recipient of ['session-manager', f.areaId, 'extra-worker']) {
|
||||
f.control.sendOwnerRequest({ actorId: 'owner', recipient, body: 'Eligible handoff' });
|
||||
}
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: true, maxTurnsPerDay: 3 });
|
||||
const starts = [];
|
||||
const finishes = new Map();
|
||||
let thirdStarted;
|
||||
const third = new Promise(resolve => { thirdStarted = resolve; });
|
||||
const run = runContinuation(f.control, { list: () => [] }, { actorId: 'owner', limit: 2,
|
||||
launch: area => { starts.push(area); if (starts.length === 3) thirdStarted();
|
||||
return new Promise(resolve => finishes.set(area, resolve)); } });
|
||||
assert.deepEqual(starts, ['session-manager', f.areaId]);
|
||||
finishes.get(f.areaId)(0);
|
||||
await third;
|
||||
assert.deepEqual(starts, ['session-manager', f.areaId, 'extra-worker']);
|
||||
assert.equal(finishes.has('session-manager'), true, 'Manager completion was not required for refill');
|
||||
finishes.get('extra-worker')(0);
|
||||
finishes.get('session-manager')(0);
|
||||
assert.equal((await run).launched.length, 3);
|
||||
assert.equal(planContinuation(f.control, { list: () => [] }).reason, 'daily-turn-cap');
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-runtime-continuation-no-progress: unfinished assignment continues once then escalates rather than busy-looping', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.control.reportStatus({ areaId: f.areaId, state: 'idle', summary: 'Idle' });
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: true, maxTurnsPerDay: 8 });
|
||||
const task = { id: 'TASK-continue', owner: f.areaId, state: 'assigned', revision: 1, dependencies: [] };
|
||||
let specialistTurns = 0;
|
||||
const result = await runContinuation(f.control, { list: () => [task] }, { actorId: 'owner', limit: 1,
|
||||
launch: area => { if (area === f.areaId) specialistTurns++; return 0; } });
|
||||
assert.equal(specialistTurns, 2);
|
||||
assert.equal(result.launched.filter(t => t.areaId === f.areaId).length, 2);
|
||||
const messages = f.control.readMessages({ recipient: 'session-manager', unacknowledgedOnly: true, limit: 1000 });
|
||||
assert.equal(messages.filter(m => m.body.includes('two turns without advancing')).length, 1);
|
||||
assert.equal(planContinuation(f.control, { list: () => [task] }).candidates.some(c => c.areaId === f.areaId), false);
|
||||
task.revision++;
|
||||
assert.equal(planContinuation(f.control, { list: () => [task] }).candidates.some(c => c.areaId === f.areaId), true);
|
||||
task.dependencies = ['TASK-unfinished'];
|
||||
assert.equal(planContinuation(f.control, { list: () => [task] }).candidates.some(c => c.areaId === f.areaId), false);
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-runtime-continuation-capacity: a manually held executor counts against automatic fleet capacity', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.control.reportStatus({ areaId: f.areaId, state: 'idle', summary: 'Idle' });
|
||||
f.control.sendOwnerRequest({ actorId: 'owner', recipient: f.areaId, body: 'Eligible handoff' });
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: true });
|
||||
let running;
|
||||
withRuntimeTurnGuard(f.control, 'session-manager', () => {
|
||||
running = runContinuation(f.control, { list: () => [] }, { actorId: 'owner', limit: 1,
|
||||
launch: () => { throw new Error('Capacity must not be exceeded'); } });
|
||||
});
|
||||
assert.deepEqual((await running).launched, []);
|
||||
assert.equal(planContinuation(f.control, { list: () => [] }).attempts, 0);
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-runtime-continuation-fairness: a fresh task revision does not let one specialist starve an unstarted peer', async () => {
|
||||
const f = fixture({ extraWorker: true });
|
||||
try {
|
||||
f.control.reportStatus({ areaId: f.areaId, state: 'idle', summary: 'Idle' });
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: true, maxTurnsPerDay: 3 });
|
||||
const task = { id: 'TASK-changing', owner: f.areaId, state: 'assigned', revision: 1, dependencies: [] };
|
||||
f.control.sendOwnerRequest({ actorId: 'owner', recipient: 'extra-worker', body: 'Peer work' });
|
||||
const starts = [];
|
||||
await runContinuation(f.control, { list: () => [task] }, { actorId: 'owner', limit: 1,
|
||||
launch: area => { starts.push(area); if (area === f.areaId) task.revision++; return 0; } });
|
||||
assert.deepEqual(starts.slice(0, 2), [f.areaId, 'extra-worker']);
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-runtime-continuation-health: budget stops, day reset, pause and unreadable ledgers are visible without mutation', async () => {
|
||||
const f = fixture({ recoveryWorktree: false });
|
||||
const at = new Date('2026-10-03T00:00:00Z');
|
||||
try {
|
||||
f.control.reportStatus({ areaId: f.areaId, state: 'idle', summary: 'Idle' });
|
||||
f.control.sendOwnerRequest({ actorId: 'owner', recipient: f.areaId, body: 'Work' });
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: true, maxTurnsPerDay: 1 });
|
||||
await runContinuation(f.control, { list: () => [] }, { actorId: 'owner', at, launch: () => 0 });
|
||||
const path = join(f.control.stateRoot, 'continuation.json');
|
||||
const before = readFileSync(path, 'utf8');
|
||||
const health = observeContinuation({ stateRoot: f.control.stateRoot, now: () => at });
|
||||
assert.equal(health.stopReason, 'daily-turn-cap');
|
||||
assert.equal(health.remainingTurns, 0);
|
||||
assert.equal(health.manualTurnsIncluded, false);
|
||||
assert.equal(health.timerInstallationVerified, false);
|
||||
const snapshot = observeProject({ repositoryRoot: f.root, env: f.env, now: () => at });
|
||||
assert.equal(snapshot.continuation.stopReason, 'daily-turn-cap');
|
||||
assert.equal(readFileSync(path, 'utf8'), before);
|
||||
assert.equal(observeContinuation({ stateRoot: f.control.stateRoot, now: () => new Date('2026-10-04T00:00:00Z') }).remainingTurns, 1);
|
||||
configureContinuation(f.control, { actorId: 'owner', enabled: false, maxTurnsPerDay: 1 });
|
||||
assert.equal(observeContinuation({ stateRoot: f.control.stateRoot, now: () => at }).stopReason, 'paused');
|
||||
writeFileSync(path, 'not-json');
|
||||
assert.equal(observeContinuation({ stateRoot: f.control.stateRoot, now: () => at }).stopReason, 'policy-or-ledger-unreadable');
|
||||
assert.equal(readFileSync(path, 'utf8'), 'not-json', 'Observation must not repair or reset the attempt ledger');
|
||||
} finally { f.control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-legacy-runtime-recovery-project: evidence cannot be applied to a different checkout with cloned project/session fields', () => {
|
||||
const first = fixture();
|
||||
const second = fixture();
|
||||
try {
|
||||
assert.throws(() => second.control.recoverOwnerStoppedUnknownIdentity({ actorId: 'owner', areaId: second.areaId,
|
||||
expectedIdentity: second.identity, evidence: first.issue().evidence }), { code: 'RUNTIME_RECOVERY_UNVERIFIED' });
|
||||
assert.equal(second.control.getAgent(second.areaId).state, 'working');
|
||||
} finally { first.control.close(); second.control.close(); }
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { installProject } from '../../src/kernel/install.mjs';
|
||||
import { defaultManagerCheckInSchedule, organizationGraphFromConfig } from '../../src/kernel/organization.mjs';
|
||||
import { createWorktrees } from '../../src/kernel/worktrees.mjs';
|
||||
import { ResourceService } from '../../src/resources/service.mjs';
|
||||
import { createTorchToolset } from '../../src/mcp/tools.mjs';
|
||||
import {
|
||||
createFleetBrief, detachFleet, planAreaUp, planFleetDetach, planFleetDown, planFleetUp, startFleet, stopFleet,
|
||||
} from '../../src/runtime/lifecycle.mjs';
|
||||
@@ -83,6 +84,43 @@ function fleetFixture({ workerRuntime = 'claude', hierarchy = false } = {}) {
|
||||
return { root, env, worker: proposal.domains[0].id, middleManager: hierarchy ? 'runtime-lead' : null };
|
||||
}
|
||||
|
||||
test('SCN-fleet-startup-inbox: fresh and resumed launch instructions retrieve newer unread handoffs beyond the default history page', () => {
|
||||
const fixture = fleetFixture();
|
||||
const control = openControlPlane({ repositoryRoot: fixture.root, env: fixture.env });
|
||||
const adapters = new Map([['claude', createClaudeAdapter({ executable: 'claude' })]]);
|
||||
let tick = 0;
|
||||
control.clock = () => new Date(Date.parse('2026-10-03T00:00:00Z') + tick++);
|
||||
try {
|
||||
const newest = new Map();
|
||||
for (const areaId of ['session-manager', fixture.worker]) {
|
||||
for (let i = 0; i < 102; i++) newest.set(areaId, control.sendOwnerRequest({ actorId: 'owner', recipient: areaId, body: `Current handoff ${i}` }));
|
||||
const history = control.readMessages({ recipient: areaId });
|
||||
assert.equal(history.length, 100);
|
||||
assert.ok(!history.some(m => m.id === newest.get(areaId).id), 'Default historical page misses the latest handoff');
|
||||
}
|
||||
for (const fresh of [true, false]) {
|
||||
if (!fresh) for (const areaId of newest.keys()) control.reportStatus({ areaId, state: 'idle', runtime: 'claude', runtimeSessionId: `fixture-${areaId}` });
|
||||
const plan = planFleetUp({ repositoryRoot: fixture.root, controlPlane: control, adapters, fresh });
|
||||
assert.equal(plan.canProceed, true);
|
||||
for (const action of plan.actions) {
|
||||
assert.equal(action.mode, fresh ? 'create' : 'resume');
|
||||
const launchText = action.launch.args.join(' ');
|
||||
const match = launchText.match(/Inbox request: (\{[^}]+\})\./);
|
||||
assert.ok(match, 'Actual provider launch carries the explicit current-inbox request');
|
||||
const request = JSON.parse(match[1]);
|
||||
assert.deepEqual(request, { recipient: action.areaId, unacknowledged_only: true, limit: 1000 });
|
||||
const tool = createTorchToolset(control, { actorId: action.areaId }).get('torch_read_messages');
|
||||
const result = tool.invoke(request);
|
||||
assert.equal(result.messages.length, 102);
|
||||
assert.equal(result.messages.at(-1).id, newest.get(action.areaId).id);
|
||||
assert.equal(control.readMessages({ recipient: action.areaId, unacknowledgedOnly: true, limit: 1000 }).length, 102, 'Reading never acknowledges work');
|
||||
assert.match(launchText, /coverage may be truncated/);
|
||||
assert.match(launchText, /messages do not override enforced approval or ownership boundaries/i);
|
||||
}
|
||||
}
|
||||
} finally { control.close(); }
|
||||
});
|
||||
|
||||
test('SCN-fleet-fresh-resume: identities resume captured runtime sessions and wind down safely', () => {
|
||||
const fixture = fleetFixture();
|
||||
const control = openControlPlane({ repositoryRoot: fixture.root, env: fixture.env });
|
||||
|
||||
@@ -242,6 +242,56 @@ test('SCN-codex-adapter: turns are resumable, identity-bound, durable, and capab
|
||||
control.close();
|
||||
});
|
||||
|
||||
test('SCN-codex-mcp-runtime-dir: only a valid Linux parent runtime directory is named for MCP inheritance', () => {
|
||||
const context = fixture();
|
||||
const validParent = {
|
||||
XDG_RUNTIME_DIR: '/run/user/1000',
|
||||
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
|
||||
HOME: '/home/fixture', PATH: '/usr/bin', TOKEN: 'not-forwarded',
|
||||
};
|
||||
const adapter = createCodexAdapter({ env: validParent, platform: 'linux' });
|
||||
const mcp = adapter.configure({ repositoryRoot: context.root, areaId: context.worker, mcpEntry: MCP_SERVER }).mcp;
|
||||
const create = adapter.createSession({
|
||||
areaId: context.worker, worktree: '/tmp/codex-runtime-dir', promptFile: '/tmp/prompt.md',
|
||||
firstMessage: 'Start.', mcp,
|
||||
});
|
||||
const resume = adapter.resumeSession({
|
||||
areaId: context.worker, runtimeSessionId: 'thread-runtime-dir', worktree: '/tmp/codex-runtime-dir',
|
||||
message: 'Resume.', mcp,
|
||||
});
|
||||
const expected = `mcp_servers.${mcp.name}.env_vars=["XDG_RUNTIME_DIR"]`;
|
||||
for (const launch of [create, resume]) {
|
||||
assert.equal(launch.launch.args.includes(expected), true);
|
||||
assert.equal(launch.launch.args.some((arg) => /env_vars=/.test(arg) && arg !== expected), false);
|
||||
assert.equal(launch.launch.args.some((arg) => arg.includes(validParent.XDG_RUNTIME_DIR)
|
||||
|| arg.includes(validParent.DBUS_SESSION_BUS_ADDRESS) || arg.includes(validParent.HOME)
|
||||
|| arg.includes(validParent.PATH) || arg.includes(validParent.TOKEN)), false);
|
||||
}
|
||||
|
||||
for (const invalid of [
|
||||
{ env: {}, platform: 'linux' },
|
||||
{ env: { XDG_RUNTIME_DIR: '' }, platform: 'linux' },
|
||||
{ env: { XDG_RUNTIME_DIR: 'relative/runtime' }, platform: 'linux' },
|
||||
{ env: validParent, platform: 'darwin' },
|
||||
]) {
|
||||
const unsupported = createCodexAdapter(invalid);
|
||||
const unsupportedMcp = unsupported.configure({
|
||||
repositoryRoot: context.root, areaId: context.worker, mcpEntry: MCP_SERVER,
|
||||
}).mcp;
|
||||
const createPlan = unsupported.createSession({
|
||||
areaId: context.worker, worktree: '/tmp/codex-runtime-dir', promptFile: '/tmp/prompt.md',
|
||||
firstMessage: 'Start.', mcp: unsupportedMcp,
|
||||
});
|
||||
const resumePlan = unsupported.resumeSession({
|
||||
areaId: context.worker, runtimeSessionId: 'thread-runtime-dir', worktree: '/tmp/codex-runtime-dir',
|
||||
message: 'Resume.', mcp: unsupportedMcp,
|
||||
});
|
||||
for (const plan of [createPlan, resumePlan]) {
|
||||
assert.equal(plan.launch.args.some((arg) => /env_vars=/.test(arg)), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('SCN-mcp-stdio: an MCP host can initialize and list the TORCH tool surface over stdio', async () => {
|
||||
const context = fixture();
|
||||
const child = spawn(process.execPath, [MCP_SERVER, '--root', context.root, '--area', context.worker], {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
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';
|
||||
|
||||
const MCP_SERVER = new URL('../../src/mcp/server.mjs', import.meta.url).pathname;
|
||||
|
||||
function installedFixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'torch-mcp-owner-reply-'));
|
||||
execFileSync('git', ['init', '-b', 'main', root]);
|
||||
execFileSync('git', ['-C', root, 'config', 'user.email', '[email protected]']);
|
||||
execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']);
|
||||
writeFileSync(join(root, 'index.js'), 'export const fixture = true;\n');
|
||||
execFileSync('git', ['-C', root, 'add', '.']);
|
||||
execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']);
|
||||
const repository = inspectRepository(root);
|
||||
const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) });
|
||||
proposal.review = { status: 'approved', reviewedAt: '2026-10-03T00:00:00Z', reviewedBy: 'fixture-owner', notes: [] };
|
||||
const env = { ...process.env, XDG_DATA_HOME: join(root, '.data') };
|
||||
installProject({ repository, proposal, env, projectId: 'mcp-owner-reply-fixture' });
|
||||
const control = openControlPlane({ repositoryRoot: root, env });
|
||||
const identities = control.listAgents().map((agent) => agent.areaId);
|
||||
control.close();
|
||||
const worker = identities.find((areaId) => areaId !== 'qa' && areaId !== 'session-manager')
|
||||
?? identities.find((areaId) => areaId !== 'qa')
|
||||
?? identities[0];
|
||||
const other = identities.find((areaId) => areaId !== worker);
|
||||
assert.ok(other, 'fixture requires a second registered Fleet identity');
|
||||
return { root, env, worker, other };
|
||||
}
|
||||
|
||||
async function connectMcp({ root, env, actorId }) {
|
||||
const child = spawn(process.execPath, [MCP_SERVER, '--root', root, '--area', actorId], {
|
||||
cwd: root, env, stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
const responses = new Map();
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk;
|
||||
const lines = stdout.split('\n');
|
||||
stdout = lines.pop();
|
||||
for (const line of lines.filter(Boolean)) {
|
||||
const message = JSON.parse(line);
|
||||
if (message.id !== undefined) responses.get(message.id)?.(message);
|
||||
}
|
||||
});
|
||||
let nextId = 0;
|
||||
const request = (method, params) => new Promise((resolve, reject) => {
|
||||
const id = ++nextId;
|
||||
const timeout = setTimeout(() => reject(new Error(`MCP response timeout. stderr: ${stderr}`)), 5000);
|
||||
responses.set(id, (response) => { clearTimeout(timeout); resolve(response); });
|
||||
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
|
||||
});
|
||||
const initialized = await request('initialize', {
|
||||
protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'torch-test', version: '1.0.0' },
|
||||
});
|
||||
assert.equal(initialized.result.serverInfo.name, 'torch-agent-fleet');
|
||||
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`);
|
||||
return {
|
||||
request,
|
||||
async close() {
|
||||
child.stdin.end();
|
||||
await new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => { child.kill('SIGTERM'); resolve(); }, 1000);
|
||||
child.once('exit', () => { clearTimeout(timeout); resolve(); });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('SCN-mcp-owner-reply-public-boundary: a bound MCP identity can reply only to its own owner request', async () => {
|
||||
const fixture = installedFixture();
|
||||
let control = openControlPlane({ repositoryRoot: fixture.root, env: fixture.env });
|
||||
const request = control.sendOwnerRequest({ actorId: 'owner', recipient: fixture.worker, body: 'Report the durable outcome.' });
|
||||
control.close();
|
||||
|
||||
const workerMcp = await connectMcp({ ...fixture, actorId: fixture.worker });
|
||||
try {
|
||||
const listed = await workerMcp.request('tools/list', {});
|
||||
const replyTool = listed.result.tools.find((tool) => tool.name === 'torch_reply_to_owner');
|
||||
assert.ok(replyTool);
|
||||
assert.deepEqual(Object.keys(replyTool.inputSchema.properties).sort(), ['body', 'request_id']);
|
||||
assert.equal(replyTool.inputSchema.additionalProperties, false);
|
||||
assert.equal(listed.result.tools.some((tool) => /owner.*conversation|history/i.test(tool.name)), false);
|
||||
|
||||
const replied = await workerMcp.request('tools/call', {
|
||||
name: 'torch_reply_to_owner', arguments: { request_id: request.id, body: 'The durable outcome is ready.' },
|
||||
});
|
||||
assert.equal(replied.result.isError, undefined);
|
||||
const reply = JSON.parse(replied.result.content[0].text);
|
||||
|
||||
control = openControlPlane({ repositoryRoot: fixture.root, env: fixture.env });
|
||||
const conversation = control.readOwnerConversation({ actorId: 'owner', requestId: request.id, limit: 10 });
|
||||
assert.deepEqual(conversation.replies.map((message) => message.id), [reply.id]);
|
||||
assert.equal(conversation.replies[0].references.replyTo, request.id);
|
||||
const audit = control.readAudit().find((event) => event.operation === 'message.reply-owner' && event.entityId === reply.id);
|
||||
assert.deepEqual(audit && { actorId: audit.actorId, details: audit.details }, { actorId: fixture.worker, details: { requestId: request.id } });
|
||||
const before = { audit: control.readAudit().length, replies: conversation.replies.length };
|
||||
control.close();
|
||||
|
||||
const schemaRefusal = await workerMcp.request('tools/call', {
|
||||
name: 'torch_reply_to_owner',
|
||||
arguments: { request_id: request.id, body: 'Attempt to select a sender.', sender: fixture.other },
|
||||
});
|
||||
assert.equal(schemaRefusal.result.isError, true);
|
||||
control = openControlPlane({ repositoryRoot: fixture.root, env: fixture.env });
|
||||
assert.equal(control.readAudit().length, before.audit);
|
||||
assert.equal(control.readOwnerConversation({ actorId: 'owner', requestId: request.id, limit: 10 }).replies.length, before.replies);
|
||||
control.close();
|
||||
} finally {
|
||||
await workerMcp.close();
|
||||
}
|
||||
|
||||
control = openControlPlane({ repositoryRoot: fixture.root, env: fixture.env });
|
||||
const beforeWrongActor = {
|
||||
audit: control.readAudit().length,
|
||||
replies: control.readOwnerConversation({ actorId: 'owner', requestId: request.id, limit: 10 }).replies.length,
|
||||
};
|
||||
control.close();
|
||||
|
||||
const otherMcp = await connectMcp({ ...fixture, actorId: fixture.other });
|
||||
try {
|
||||
const wrongActor = await otherMcp.request('tools/call', {
|
||||
name: 'torch_reply_to_owner', arguments: { request_id: request.id, body: 'A valid identity cannot reply for another agent.' },
|
||||
});
|
||||
assert.equal(wrongActor.result.isError, true);
|
||||
assert.match(wrongActor.result.content[0].text, /owner request addressed to the sender/i);
|
||||
} finally {
|
||||
await otherMcp.close();
|
||||
}
|
||||
|
||||
control = openControlPlane({ repositoryRoot: fixture.root, env: fixture.env });
|
||||
assert.equal(control.readAudit().length, beforeWrongActor.audit);
|
||||
assert.equal(control.readOwnerConversation({ actorId: 'owner', requestId: request.id, limit: 10 }).replies.length, beforeWrongActor.replies);
|
||||
control.close();
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
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';
|
||||
|
||||
function installedFixture({ projectId = 'owner-reply-fixture', sequencePrefix = 'main' } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'torch-owner-reply-'));
|
||||
execFileSync('git', ['init', '-b', 'main', root]);
|
||||
execFileSync('git', ['-C', root, 'config', 'user.email', '[email protected]']);
|
||||
execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']);
|
||||
writeFileSync(join(root, 'index.js'), 'export const fixture = true;\n');
|
||||
execFileSync('git', ['-C', root, 'add', '.']);
|
||||
execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']);
|
||||
const repository = inspectRepository(root);
|
||||
const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) });
|
||||
proposal.review = { status: 'approved', reviewedAt: '2026-10-03T00:00:00Z', reviewedBy: 'fixture-owner', notes: [] };
|
||||
const env = { ...process.env, XDG_DATA_HOME: join(root, '.data') };
|
||||
installProject({ repository, proposal, env, projectId });
|
||||
let sequence = 0;
|
||||
let seconds = 0;
|
||||
return {
|
||||
root, env,
|
||||
worker: proposal.domains.find((domain) => domain.id !== 'qa')?.id ?? proposal.domains[0].id,
|
||||
options: {
|
||||
repositoryRoot: root, env,
|
||||
idFactory: () => `${sequencePrefix}-${++sequence}`,
|
||||
clock: () => new Date(Date.UTC(2026, 9, 3, 12, 0, seconds++)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('SCN-owner-addressed-reply-durability: a registered agent replies to its owner request with durable attribution', () => {
|
||||
const fixture = installedFixture();
|
||||
let control = openControlPlane(fixture.options);
|
||||
const request = control.sendOwnerRequest({ actorId: 'owner', recipient: fixture.worker, body: 'Report the persisted outcome.' });
|
||||
const first = control.sendOwnerReply({ sender: fixture.worker, requestId: request.id, body: 'The durable outcome is ready.' });
|
||||
const second = control.sendOwnerReply({ sender: fixture.worker, requestId: request.id, body: 'The evidence remains bounded.' });
|
||||
control.close();
|
||||
|
||||
control = openControlPlane(fixture.options);
|
||||
const firstPage = control.readOwnerConversation({ actorId: 'owner', requestId: request.id, limit: 1 });
|
||||
assert.equal(firstPage.request.id, request.id);
|
||||
assert.deepEqual(firstPage.replies.map((message) => message.id), [first.id]);
|
||||
assert.equal(firstPage.replies[0].recipient, 'owner');
|
||||
assert.equal(firstPage.replies[0].references.replyTo, request.id);
|
||||
assert.equal(firstPage.page.truncated, true);
|
||||
const secondPage = control.readOwnerConversation({ actorId: 'owner', requestId: request.id, cursor: firstPage.page.nextCursor, limit: 1 });
|
||||
assert.deepEqual(secondPage.replies.map((message) => message.id), [second.id]);
|
||||
assert.equal(secondPage.page.complete, true);
|
||||
assert.equal(secondPage.page.nextCursor, null);
|
||||
const audit = control.readAudit().find((event) => event.operation === 'message.reply-owner' && event.entityId === first.id);
|
||||
assert.deepEqual(audit && { actorId: audit.actorId, details: audit.details }, { actorId: fixture.worker, details: { requestId: request.id } });
|
||||
control.close();
|
||||
});
|
||||
|
||||
test('SCN-owner-addressed-reply-refusal: forged owner recipients, senders, and references leave durable state untouched', () => {
|
||||
const fixture = installedFixture();
|
||||
const foreign = installedFixture({ projectId: 'foreign-owner-reply-fixture', sequencePrefix: 'foreign' });
|
||||
const control = openControlPlane(fixture.options);
|
||||
const request = control.sendOwnerRequest({ actorId: 'owner', recipient: fixture.worker, body: 'Respond only from your registered identity.' });
|
||||
const foreignControl = openControlPlane(foreign.options);
|
||||
const foreignRequest = foreignControl.sendOwnerRequest({ actorId: 'owner', recipient: foreign.worker, body: 'Foreign request.' });
|
||||
foreignControl.close();
|
||||
const beforeAudit = control.readAudit().length;
|
||||
const beforeMessages = control.readMessages({ recipient: fixture.worker }).length;
|
||||
const beforeConversation = control.readOwnerConversation({ actorId: 'owner', requestId: request.id, limit: 10 });
|
||||
assert.deepEqual(beforeConversation.replies, []);
|
||||
|
||||
assert.throws(() => control.sendMessage({ sender: fixture.worker, recipient: 'owner', body: 'Bypass the reply boundary.' }), (error) => error.code === 'UNKNOWN_FLEET_IDENTITY');
|
||||
assert.throws(() => control.sendOwnerReply({ sender: 'owner', requestId: request.id, body: 'Spoofed sender.' }), (error) => error.code === 'UNKNOWN_FLEET_IDENTITY');
|
||||
assert.throws(() => control.sendOwnerReply({ sender: 'session-manager', requestId: request.id, body: 'Valid identity, wrong addressee.' }), (error) => error.code === 'OWNER_REPLY_REFERENCE_INVALID');
|
||||
assert.throws(() => control.sendOwnerReply({ sender: fixture.worker, requestId: foreignRequest.id, body: 'Foreign reference.' }), (error) => error.code === 'OWNER_REPLY_REFERENCE_INVALID');
|
||||
assert.throws(() => control.sendOwnerReply({ sender: fixture.worker, requestId: 'forged-message-id', body: 'Forged reference.' }), (error) => error.code === 'OWNER_REPLY_REFERENCE_INVALID');
|
||||
assert.throws(() => control.readOwnerConversation({ actorId: fixture.worker, requestId: request.id, limit: 1 }), (error) => error.code === 'OWNER_AUTHORITY_REQUIRED');
|
||||
assert.equal(control.readAudit().length, beforeAudit);
|
||||
assert.equal(control.readMessages({ recipient: fixture.worker }).length, beforeMessages);
|
||||
const afterConversation = control.readOwnerConversation({ actorId: 'owner', requestId: request.id, limit: 10 });
|
||||
assert.deepEqual(afterConversation.replies, []);
|
||||
assert.deepEqual(afterConversation.page, beforeConversation.page);
|
||||
const direct = control.sendMessage({ sender: 'session-manager', recipient: fixture.worker, body: 'Existing direct delivery.' });
|
||||
const broadcast = control.sendMessage({ sender: fixture.worker, recipient: 'all', body: 'Existing broadcast delivery.' });
|
||||
assert.deepEqual(control.readMessages({ recipient: fixture.worker }).slice(-2).map((message) => message.id), [direct.id, broadcast.id]);
|
||||
control.close();
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chmodSync, existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import test from 'node:test';
|
||||
import { openControlPlane, openControlPlaneReadOnly } 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 { projectStatePath } from '../../src/kernel/paths.mjs';
|
||||
import { setupProject } from '../../src/kernel/setup.mjs';
|
||||
|
||||
const now = () => new Date('2026-10-03T10:00:00Z');
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'torch-read-only-control-plane-'));
|
||||
const git = (args) => execFileSync('git', ['-C', root, ...args], {
|
||||
env: { ...process.env, GIT_AUTHOR_DATE: '2026-10-03T10:00:00Z', GIT_COMMITTER_DATE: '2026-10-03T10:00:00Z' },
|
||||
});
|
||||
git(['init', '-b', 'main']);
|
||||
git(['config', 'user.email', '[email protected]']);
|
||||
git(['config', 'user.name', 'TORCH Test']);
|
||||
writeFileSync(join(root, 'app.js'), 'export const value = 1;\n');
|
||||
git(['add', '.']);
|
||||
git(['commit', '-m', 'fixture']);
|
||||
const repository = inspectRepository(root);
|
||||
const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) });
|
||||
proposal.review = { status: 'approved', reviewedAt: now().toISOString(), reviewedBy: 'owner', notes: [] };
|
||||
const env = { ...process.env, XDG_DATA_HOME: mkdtempSync(join(tmpdir(), 'torch-read-only-state-')) };
|
||||
const projectId = 'read-only-fixture';
|
||||
installProject({ repository, proposal, env, projectId });
|
||||
setupProject({ repository: inspectRepository(root), env, parentOverride: join(tmpdir(), `${basename(root)}-trees`), authorized: true });
|
||||
const writer = openControlPlane({ repositoryRoot: root, env, clock: now });
|
||||
const worker = proposal.domains[0].id;
|
||||
writer.reportStatus({ areaId: worker, state: 'idle', summary: 'registered v2 snapshot' });
|
||||
writer.close();
|
||||
const stateRoot = projectStatePath(projectId, env);
|
||||
const stable = new DatabaseSync(join(stateRoot, 'state.db'));
|
||||
stable.exec('PRAGMA journal_mode = DELETE');
|
||||
stable.close();
|
||||
return { root, env, worker, stateRoot };
|
||||
}
|
||||
|
||||
function sidecars(stateRoot) {
|
||||
return readdirSync(stateRoot).filter((name) => name === 'state.db-wal' || name === 'state.db-shm').sort();
|
||||
}
|
||||
|
||||
test('SCN-read-only-v2-control-plane: a write-refusing state directory yields a stable v2 snapshot and rejects mutation', (t) => {
|
||||
const context = fixture();
|
||||
const statePath = join(context.stateRoot, 'state.db');
|
||||
const before = readFileSync(statePath);
|
||||
const beforeSidecars = sidecars(context.stateRoot);
|
||||
assert.deepEqual(beforeSidecars, [], 'fixture must prove sidecar-free reader behavior');
|
||||
chmodSync(statePath, 0o444);
|
||||
chmodSync(context.stateRoot, 0o555);
|
||||
t.after(() => {
|
||||
chmodSync(context.stateRoot, 0o755);
|
||||
chmodSync(statePath, 0o644);
|
||||
});
|
||||
const reader = openControlPlaneReadOnly({ repositoryRoot: context.root, env: context.env, clock: now });
|
||||
assert.equal(reader.identity(context.worker).summary, 'registered v2 snapshot');
|
||||
assert.deepEqual(reader.observeMessages({ recipient: context.worker, selection: 'unread', limit: 1 }), {
|
||||
recipient: context.worker,
|
||||
messages: [],
|
||||
observation: { selection: 'unread', requestedLimit: 1, returnedCount: 0, complete: true, truncated: false, pendingUnreadCount: 0 },
|
||||
});
|
||||
assert.throws(
|
||||
() => reader.reportStatus({ areaId: context.worker, state: 'working', summary: 'must not persist' }),
|
||||
(error) => error.code === 'CONTROL_PLANE_READ_ONLY',
|
||||
);
|
||||
reader.close();
|
||||
assert.deepEqual(readFileSync(statePath), before, 'the database bytes remain unchanged');
|
||||
assert.deepEqual(sidecars(context.stateRoot), beforeSidecars, 'the reader creates no WAL or SHM sidecar');
|
||||
});
|
||||
|
||||
test('SCN-read-only-v2-control-plane-race: a concurrent v2 writer produces one coherent snapshot or a typed refusal', () => {
|
||||
const context = fixture();
|
||||
const statePath = join(context.stateRoot, 'state.db');
|
||||
const wal = new DatabaseSync(statePath);
|
||||
wal.exec('PRAGMA journal_mode = WAL');
|
||||
wal.close();
|
||||
const writer = new DatabaseSync(statePath);
|
||||
writer.exec('BEGIN IMMEDIATE');
|
||||
writer.prepare('UPDATE identities SET summary = ?, updated_at = ? WHERE area_id = ?')
|
||||
.run('writer committed after reader snapshot', '2026-10-03T10:01:00Z', context.worker);
|
||||
let reader;
|
||||
let refusal = null;
|
||||
try { reader = openControlPlaneReadOnly({ repositoryRoot: context.root, env: context.env, clock: now }); } catch (error) { refusal = error; }
|
||||
writer.exec('COMMIT');
|
||||
writer.close();
|
||||
if (refusal) {
|
||||
assert.equal(refusal.code, 'CONTROL_PLANE_READ_ONLY_UNAVAILABLE');
|
||||
return;
|
||||
}
|
||||
const observed = reader.identity(context.worker).summary;
|
||||
assert.ok(observed === 'registered v2 snapshot' || observed === 'writer committed after reader snapshot', `reader must expose one whole SQLite snapshot, received ${observed}`);
|
||||
reader.close();
|
||||
});
|
||||
|
||||
test('SCN-read-only-pinned-correlated-snapshot: one reader cannot combine generations across an atomic writer commit', () => {
|
||||
const context = fixture();
|
||||
const statePath = join(context.stateRoot, 'state.db');
|
||||
const generationA = '2026-10-03T10:00:00.000Z';
|
||||
const generationB = '2026-10-03T10:01:00.000Z';
|
||||
const correlated = [context.worker, 'session-manager'];
|
||||
const seed = new DatabaseSync(statePath);
|
||||
seed.exec('PRAGMA journal_mode = WAL');
|
||||
const setGeneration = seed.prepare('UPDATE identities SET summary = ?, updated_at = ? WHERE area_id = ?');
|
||||
for (const areaId of correlated) setGeneration.run('generation A', generationA, areaId);
|
||||
seed.close();
|
||||
|
||||
const reader = openControlPlaneReadOnly({ repositoryRoot: context.root, env: context.env, clock: now });
|
||||
const first = reader.identity(context.worker);
|
||||
assert.deepEqual({ summary: first.summary, updatedAt: first.updatedAt }, { summary: 'generation A', updatedAt: generationA });
|
||||
const writer = new DatabaseSync(statePath);
|
||||
writer.exec('BEGIN IMMEDIATE');
|
||||
const update = writer.prepare('UPDATE identities SET summary = ?, updated_at = ? WHERE area_id = ?');
|
||||
for (const areaId of correlated) update.run('generation B', generationB, areaId);
|
||||
writer.exec('COMMIT');
|
||||
writer.close();
|
||||
let second;
|
||||
try { second = reader.identity('session-manager'); } catch (error) { assert.equal(error.code, 'CONTROL_PLANE_READ_ONLY_UNAVAILABLE'); }
|
||||
if (second) {
|
||||
assert.deepEqual({ summary: second.summary, updatedAt: second.updatedAt }, { summary: 'generation A', updatedAt: generationA }, 'the same reader must not combine generations');
|
||||
}
|
||||
reader.close();
|
||||
const fresh = openControlPlaneReadOnly({ repositoryRoot: context.root, env: context.env, clock: now });
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(correlated.map((areaId) => {
|
||||
const identity = fresh.identity(areaId);
|
||||
return [areaId, { summary: identity.summary, updatedAt: identity.updatedAt }];
|
||||
})),
|
||||
Object.fromEntries(correlated.map((areaId) => [areaId, { summary: 'generation B', updatedAt: generationB }])),
|
||||
);
|
||||
fresh.close();
|
||||
});
|
||||
|
||||
test('SCN-read-only-v2-control-plane-schema: unknown schema revisions are refused instead of guessed', () => {
|
||||
const context = fixture();
|
||||
const statePath = join(context.stateRoot, 'state.db');
|
||||
const writer = new DatabaseSync(statePath);
|
||||
writer.exec('PRAGMA user_version = 3');
|
||||
writer.close();
|
||||
assert.ok(existsSync(statePath));
|
||||
assert.throws(
|
||||
() => openControlPlaneReadOnly({ repositoryRoot: context.root, env: context.env }),
|
||||
(error) => error.code === 'CONTROL_PLANE_READ_ONLY_UNAVAILABLE',
|
||||
);
|
||||
});
|
||||
|
||||
test('SCN-read-only-v2-control-plane-input-drift: tracked identity inputs changing after snapshot make reads unavailable', () => {
|
||||
const context = fixture();
|
||||
const rosterPath = join(context.root, '.torch', 'roster.yaml');
|
||||
const original = readFileSync(rosterPath, 'utf8');
|
||||
const reader = openControlPlaneReadOnly({ repositoryRoot: context.root, env: context.env });
|
||||
writeFileSync(rosterPath, `${original}\n`);
|
||||
assert.throws(() => reader.identity(context.worker), (error) => error.code === 'CONTROL_PLANE_READ_ONLY_UNAVAILABLE');
|
||||
writeFileSync(rosterPath, original);
|
||||
reader.close();
|
||||
});
|
||||
Reference in new issue
Block a user