Skip to content

DO NOT MERGE — merge train: 2657,2681,2680,2656,2647 - #2686

Closed
vybe wants to merge 20 commits into
devfrom
train/20260910-1327
Closed

vybe wants to merge 20 commits into
devfrom
train/20260910-1327

Conversation

@vybe

@vybe vybe commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Integration surface for #2657, #2681, #2680, #2656, #2647.

This PR is never merged. It exists only to run the full suite over the five members together, because every member is tested against dev and never against its siblings. Members merge individually once this is green; the branch is then deleted.

No closing keywords here on purpose — the members carry those, and the train must promote and close nothing.

Batch validated 2026-09-10. Ejected this round: #2532 (pre-created RUNNING rows bulk-FAILed by the #106 sweep, reproduced), #2619 (search-to-one-match makes the match unreachable). Held: #2675 (ours; two exits still wedge the 24h claim).

dolho and others added 20 commits September 9, 2026 13:13
…ts box (#2640)

Two defects, one report.

**The layout jumped.** `Portal.vue` toggled `<main>` between `flex-1` and
`sm:flex-[2_1_0%]` on `voiceCall.active` and swapped `PortalRail` for
`PortalVoiceCanvas` in the same frame. Nothing transitioned, so both columns
landed at their new shares in one paint, and End call jumped back.

`flex-grow` is a `<number>` and therefore animatable, so the share now
transitions — 300 ms ease-out on `<main>`, and the canvas column ramps its own
grow 0 → 3 over the same curve. The ramp is expressed as Vue enter/leave
classes rather than a class toggle because a newly inserted element has no
value to transition FROM. Opacity rides the same transition so the canvas's
content is not re-wrapping in view while the column is still moving.
`motion-reduce:transition-none` on every transitioning element.

The shares stay shares: reverting to `w-[40%]` / `w-[60%]` would animate just
as well and re-open #2581, where those summed to 100% + an 18rem sidebar and
the shell clipped the canvas column off the right edge.

Two consequences, both deliberate:

* The `v-if` / `v-else-if` chain is gone — a `<Transition>` wrapper breaks the
  adjacency a chain needs. The exclusivity it guaranteed by construction is now
  a named computed both arms read, so they cannot drift into both claiming the
  column.
* The rail waits for the canvas to finish leaving. Vue keeps a leaving element
  in the DOM for its transition; without the gate the rail would mount at full
  fixed width beside a canvas that is still shrinking — three columns in a row
  sized for two, `<main>` squeezed by flex for 300 ms, a worse jump than the one
  being fixed.

**The orb rendered squashed.** `VoiceOverlay.vue::resizeCanvas` sized the
bitmap ONCE, from the `watch(canvasEl)` that fires on mount — no
ResizeObserver, no window listener, no per-frame check — while the canvas is
`absolute inset-0 w-full h-full`. Every later width change left CSS stretching
a stale bitmap into an ellipse, and the overlay mounts in the same tick the call
re-lays out the columns, so the single measurement could capture the pre-call
width on its own.

It now observes both: a ResizeObserver for the box moving under a stable window
(the column swap, a rail drag), and a window `resize` for a devicePixelRatio
change, which resizes no box and so fires no observer. The bitmap is sized at
`css × dpr` (capped at 2) and the render loop draws in CSS pixels via
`ctx.setTransform`, so the 45px core and the particles' fixed radii keep meaning
what they meant. Resizing re-scales and never re-seeds — the particle field is
seeded once and lives in a fixed space around (0,0), so the orb does not restart
when the column moves. A zero-sized box is ignored rather than throwing the last
good size away, and a same-size measurement does not touch the bitmap, because
assigning to `canvas.width` clears the canvas.

Tests: `portalVoiceLayoutMotion.spec.js` (17) — the resize contract EXECUTED
against a stub canvas whose box changes (DPR scaling, the cap, a missing DPR,
the zero-box and same-size guards), mutation-checked by pinning the bitmap to
its first measurement, which fails exactly the "FOLLOWS a changed box" case;
plus the transition classes, the reduced-motion fallback counted over every
transitioning element, and the leave gate. Two existing guards in
`portalVoiceMode.spec.js` / `portalRail.spec.js` were rewritten to read the
shared condition instead of the retired chain — the property they protect is
exclusivity, not which construct expresses it.

Frontend suite: 113 files, 2532 tests, all passing; both ratchets green; vite
build clean.

Related to #2640

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
…ent#535)

`run_task` from a Workspace voice call went to the agent container's task
endpoint: a stateless run with a 30s timeout, no thread, no memory of the
conversation the person was in. In the issue's words, that is "what makes
voice just chat today".

A Workspace call now runs the turn through `portal_chat` — the SAME
pipeline a typed message takes — into the thread the call is bound to, on
the thread's own `cached_claude_session_id`. The agent has its skills,
its files, its memory and its mid-work state, and the answer lands in
that chat as a turn (and on the canvas if it drew). A call with no thread
(VoIP, the legacy Agent Detail session) keeps the container path, because
there is nothing to run it in. The split is `_is_workspace_bound`, which
requires BOTH `portal_session_id` and `client_email`: a thread with no
email cannot be attributed, an email with no thread has nowhere to land,
and either alone would silently fall back to the container.

**The spoken budget is not a cancellation.** Past `_SPOKEN_BUDGET_SECONDS`
(20s) the model is told the work is still running and keeps the floor,
while the turn CONTINUES and its reply lands in the chat. The old 30s
`wait_for` cancelled it — throwing away work already done and paid for.
The detached turn is strongly referenced so it cannot be collected
mid-flight (the #1083 footgun), and it deliberately outlives the call: a
turn the person asked for is worth landing whether or not they are still
on the line. `_on_tool_result` fires when it lands, so a badge clears on
the real event rather than on a timer.

**The manifest is locked.** `services/voice_tools.py` owns the policy:
resolved once at session start, `_build_live_config` builds the config
FROM it, and the dispatcher refuses any name outside it before reading an
argument. A per-agent declaration may only NARROW — `template.yaml` is
agent-writable, so a declaration that could ADD would let an agent grant
itself a capability by editing itself. Fleet tools are absent by
construction: `PLATFORM_VOICE_TOOLS` is the only door a name enters
through.

Two defects found while building it, both fixed here:

* the manifest was read as `session.tool_manifest or default`, so an
  agent declaring `voice.tools: []` — the strongest narrowing — fell
  through to the FULL platform set. The field is tri-state now: `None` is
  "never resolved" (→ the safe default), `frozenset()` is a decision.
* `_execute_and_respond` read a nameless tool call as `run_task`
  (`getattr(fc, 'name', 'run_task')`), sending the model's arguments to
  the agent under a name nobody chose. It is refused now, and the refusal
  ANSWERS the call — a model that never receives a response for a call it
  made stops speaking.

Scoped out, as decisions rather than omissions:

* **the template-declared narrowing is mechanism-only.** `resolve_manifest`
  takes and honours a declaration, and `create_session` threads it — but
  nothing reads one yet, because `/api/template/info` does not expose a
  `voice` block. Adding it means an agent-server field plus a base-image
  rebuild, and a reader against a field no deployed agent returns would
  be a feature that reads as working and does nothing.
* **the orb badge is backend-only.** The session counts in-flight turns
  and the landing fires `_on_tool_result`; rendering it is frontend work
  this PR does not do.

`_execute_tool` keeps its original `(agent_name, …)` contract — the
routing moved to the dispatcher, which already holds the session — so the
existing container-path tests still exercise the real thing. Two panel
tests move to `workspace_mode=True`: the canvas tools only exist in a
workspace session, which is the combination the model could ever produce.

23 new tests, mutation-checked (a cancelling budget, a widening union,
and a dropped refusal each turn the suite red). 1001 passed across the
voice, canvas, portal and VoIP families.

Related to Abilityai/trinity-enterprise#535

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rseded planning docs

The migration's state lived across eleven planning/testing documents plus the tracker,
and they had drifted apart. Five of them were finished work that was never retired: they
were last touched on 2026-07-08, the day the Phase 0-3 PR merged, and their conclusions
had already been folded into TARGET_ARCHITECTURE.md.

Leaving them in the tree is not free. EFFECT_IDEMPOTENCY_DIRECTION_B_RETRY_CONTEXT.md is
a *proposal* whose content was adopted into the spec; it frames the fail-closed
execution_id policy as an open question, while the spec states it as settled. A reader
who finds the proposal first concludes the policy is undecided and re-opens a closed
decision. That happened, and it cost a full review cycle.

Deleted (git retains them; nothing in tests, CI or scripts referenced any of them):

  PULL_PILOT_946_SOAK.md                        #946 closed; the decision record was
                                                never filled in - it is a blank scaffold
  EFFECT_IDEMPOTENCY_1084_REVIEW_NOTE.md        #1084 closed; reviews Direction A, retired
  EFFECT_IDEMPOTENCY_DIRECTION_B_RETRY_CONTEXT  adopted into TARGET_ARCHITECTURE.md (#1404)
  ORCHESTRATION_BUG_META_ANALYSIS_2026-06.md    its own §5 is titled "applied 2026-06-06";
                                                the spec header records the three changes
  TRANSACTIONAL_EXECUTIONS_2026-06.md           June planning artifact, nothing points at it

Archiving rather than deleting was considered and rejected: an archived file is still in
the working tree, still grepped, still loaded into an agent's context, and still read by
someone who does not scroll up to the "superseded" header - which is the exact failure
mode above. Git history is the durable copy.

PULL_MIGRATION_STATUS.md is rewritten as the entry point and verified against dev and the
tracker. It was materially wrong: Phase 4 read "not started" (it is built and in review),
#1401/#1402 read "not yet built" (closed in July), and it named a branch that no longer
exists. It now also carries two things that were recorded nowhere - the trigger reach
table (six of nine on dev, nine with Phase 4), and the behaviour the loop and fan-out
rewrites change on installs with an EMPTY pilot allowlist, which is not flag-gated and is
operator-visible.

Corrected across the surviving docs:

- The Phase-5 ">=2-week zero-orphan soak" bar was cited to #856 in four places. #856 sets
  no such bar - it is a closed spike on multi-agent fleet stability and does not mention
  duration, orphans or pull. The figure originates in #429's gating condition for #306
  (the event bus, closed 2026-04-21) and was carried over. #1766's own criterion asks for
  "several days". Flagged for deliberate re-derivation rather than silently re-fixed.
- T6.3 now states that the fail-closed policy is DECIDED in the spec and that #2392 is the
  build, not the decision.
- M5 records the first end-to-end observation of lease expiry, re-delivery and poison-park
  outside the 2026-07-08 synthetic pilot (2026-09-09, local PG, forced fault): re-delivery
  preserved the execution id and recovered the work on a different worker, and killing on
  every claim walked the counter to the cap and parked it with a high-priority operator
  alert. Every production window so far has reported redelivery_count=0, which until now
  was indistinguishable from "untested".

The five surviving reference docs get a banner pointing at the status file. Net: eleven
documents to six, one entry point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Tm7UEkd4G9KQZD5oeLRSa
… step (#2640)

Two review findings.

1. **Reduced motion was not instant.** Tailwind's `transition-none` emits only
   `transition-property: none` — the `duration-300` beside it still applies, so
   `transitionDuration` stays `.3s`. That is the exact property Vue's
   `getTransitionInfo` reads to size the fallback timer it resolves
   `@after-leave` on, so under `prefers-reduced-motion` nothing animated and
   every leave was still gated for 300ms: the canvas vanished, the right column
   sat empty, then the rail popped in. `motion-reduce:duration-0` on every
   transitioning element drives that timeout to 0. Verified against Tailwind's
   own output — the variant emits `transition-duration: 0s` inside the media
   query and after `duration-300`, so it wins.

   The comment on `voiceCanvasLeaving` claimed "`after-leave` fires immediately
   and this is never observably true", which was false as written; it now says
   what makes it true. Same correction in the feature flow.

2. **The rail column still steps** — accepted here, tracked at #2676. Its
   `<aside>` carries no width transition and it is a `shrink-0` flex sibling of
   `<main>`, so on call end it mounts at full width in one frame: 48px
   collapsed, 384px open, or the dragged `--ws-rail`, which on a wide rail is a
   bigger step than the 211px snap this PR removes. The honest fix is an
   explicitly animatable width for that column — CSS cannot transition to
   `auto` — and that width is owned by ent#492, not by the voice-call code.
   Doing it from here means either a wrapper element in the row or holding the
   rail mounted through a call, both of which want a browser to verify rather
   than the node-env source scan this suite is limited to. Recorded as a Known
   Limitation in the feature flow, with a test that fails if the limitation is
   deleted from the record or if the rail starts animating.

Tests: the class-counting case now requires BOTH reduced-motion classes and
asserts the ordering relation, since asserting the class string rather than the
behaviour is exactly why (1) shipped green.

113 files / 2534 tests green.

Fixes #2640

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
…on the session (ent#535)

**The critical.** The Redis session blob omitted `tool_manifest`, so the
cross-worker rebuild silently unlocked it: `get_session` passed nothing,
`_session_manifest` read `None` as "never resolved", and the reconstruction
handed the model the FULL platform default — in the `LiveConnectConfig` and in
the dispatcher, with no log line, because from that worker's view nothing had
ever been narrowed. Production runs `--workers 2` and the WebSocket routinely
lands on a worker other than the one `/voice/start` ran on, so this is the
normal path, not an edge case. The block comment directly above that dict says
"EVERY field a reconstructed session decides on must be here"; this PR added a
decision field and did not.

It is stored as `sorted(...)`/`null` — `json.dumps` cannot serialize a set, so
writing the frozenset raw would raise inside the try and lose the whole blob —
and read back by `_manifest_from_meta`, which maps three inputs onto two
answers: absent or `null` → unresolved (the pre-ent#535 default, and the
mid-deploy case); a list, INCLUDING `[]` → a decision; anything else → unresolved
rather than a crash in the audio loop. Collapsing `null` and `[]` would
reintroduce the exact inversion this PR fixed.

`test_create_session_writes_redis` asserted three named keys and never round-trip
completeness, which is why this shipped green. It is now a SUBSET relation over
the session's own dataclass fields, with the deliberately-unpersisted ones listed
by reason, so a field added tomorrow fails the guard instead of shipping
unpersisted.

**Also folded in, all from the review:**

* `_portal_turn` had 15 unreachable lines below its `return`, copy-pasted from
  `_execute_tool` and referencing names not in that scope. Removed, with an AST
  guard so nothing lands after that return again.
* The empty-prompt guard is back on the chat path. `portal_chat` calls
  `_persist_user_turn` unconditionally, so a blank `run_task` durably wrote an
  empty user row into the person's Workspace thread and dispatched a real,
  cost-tracked execution; `required=["prompt"]` makes that unlikely, not
  impossible. Stripped rather than falsy, and worded identically on both paths.
* `include_owned=True` is no longer a constant at the turn site. It travels on
  the session as `is_platform` (default False), written by
  `start_workspace_voice` — the function that refuses a non-platform caller, so
  the gate that authorizes the wider roster read is the one that records it.
  Sound today because that function is the only writer of `portal_session_id` +
  `client_email`; a future path setting both would otherwise widen
  `agent_on_roster` with no change at that line (Invariant #8).
  `canvas_audience` already travels for exactly this reason.
* `docs/memory/requirements/runtimes.md` §29.7 (VOICE-007) rewritten — it still
  claimed a 30s timeout and `_execute_and_respond()` → `POST /chat`, both false
  for the Workspace path since this PR. AC 7 said it had been; only the
  feature-flow had.

Removing the persisted field reds three of the new tests; verified by deleting
it and re-running. 848 passed across every voice / portal test in the suite.

Related to Abilityai/trinity-enterprise#535

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
…log copy (#2281)

DigitalOcean granted Vendor Portal access and named
`digitalocean/marketplace-partners` as the process of record. Auditing the
bundle against that repo and against `droplet-1-clicks@master` found four gaps.
Three are fixed here; the fourth needs a measurement.

**Security updates were never installed.** Their `99-img-check.sh` scores a
pending security update as a hard `[FAIL]`, not a warning, and any FAIL exits
non-zero — which fails the build at the last provisioner, furthest from the
cause. Nothing in the bundle ran an upgrade, so every green build so far was
luck about what the base image carried that day, or about whether Ubuntu's own
boot-time timers happened to land first. Neither is ours to own. Adds the
`full-upgrade` from their reference `marketplace-image.json`, before the first
install so the packages we add are patched too.

**`X-DO-MARKETPLACE` was missing** from the Caddyfile first boot writes. Rule 10
of their build standard specifies it on the reverse-proxy block and their own
catalog apps ship it. It breaks nothing at runtime, which is why it survived
review — it is visible only to a Marketplace reviewer.

**There was no `listing.md`.** Rule 11 asks for catalog copy in-repo so the page
and the image are versioned together. Ours lived nowhere. It is also where the
>=8 GB sizing guidance and the "DigitalOcean does not build or support Trinity"
line required by the vendor terms actually get written down.

**Submission is now automatable.** `manifest` + `shell-local` post-processors
read the snapshot id from the build's own record and PATCH it to the vendor API
with the real payload (`imageId` required, plus `reasonForUpdate`, `osVersion`,
`softwareIncluded[]`). A `post-processors` CHAIN rather than sibling blocks,
because shell-local reads what manifest writes and siblings run in parallel.
`mp-submit.sh` is a no-op unless `TRINITY_DO_APP_ID` is set, so a plain
`packer build` still just builds, and it names the 400 that means "a previous
submission is still in review" — which otherwise reads as a bad token.

The runbook gains a **submission gate**: nothing goes to DigitalOcean until
another team member has QA'd a droplet from that snapshot. A green `img_check`
says the image is acceptable, not that Trinity works on it.

**Still open: `build_size`.** It is `s-2vcpu-4gb` (80 GB), and the comment above
it claimed the opposite of the value. The build droplet's CPU and RAM never
reach the snapshot — Trinity does not run during the build — but the disk size
does, and DigitalOcean does not allow shrinking it. So 80 GB excludes every plan
with a smaller boot disk, and on the v5 line disk is decoupled from memory:
`s5-8vcpu-16gb-30gb` is 16 GiB of RAM on a 30 GiB disk, exactly the plan a
Trinity operator should pick, and this snapshot cannot deploy to it. The comment
now states the real criterion — the smallest boot disk the baked images fit on —
and the value waits on measuring them.

Guards are mutation-checked: reverting any one fix turns its test red. The
no-op default is executed rather than pattern-matched, per #2522.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sj48trT3XmyaSus4GUtXB
Previewing a text file containing one unbreakable token — a long path, a
URL, a base64 blob, a run of `===` — grew the whole preview pane far past
the visible area. The panel root is `overflow-hidden` and nothing in the
chain scrolls horizontally, so the overflow was unreachable: every line
was cut off at the right edge with no scrollbar to drag. Because the
`<pre>` is `whitespace-pre-wrap`, ordinary prose then wrapped at the
blown-out width too, so the whole preview was clipped, not just the token.

Two classes, each fixing a different half:

- `FilesPanel.vue` — `min-w-0` on the preview column. As a flex item at
  the default `min-width: auto` it could not shrink below the min-content
  width of its contents, which the token defined.
- `FilePreview.vue` — `break-words` on the `<pre>`. Wrap rather than
  horizontal scroll, deliberately: it matches the in-repo precedent
  `PortalFilePreview.vue`, it is what the design-system contract asks for
  (bounded viewport, unbounded data), and a scrollbar on a `<pre>` that
  can be 80 KB tall sits below the fold and is effectively unreachable.

Measured in Chromium at a 1280px viewport on a file holding one 320-char
unbreakable token, reading scrollWidth/clientWidth:

  neither           root 3048/1280  pane 2768  pre 2720     sidebar 280px
  break-words only  root 3048/1280  pane 2768  pre 2720     sidebar 280px
  min-w-0 only      root 1280/1280  pane  960  pre 2720/912
  both              root 1280/1280  pane  960  pre  912/912 sidebar 320px

`break-words` alone changes nothing — with `min-width: auto` the pane
still sizes to min-content, which a break-word rule does not reduce.
`min-w-0` alone stops the pane growing but leaves the token overflowing
into the `<pre>`'s own `overflow-auto`, i.e. the below-the-fold scrollbar.
The sidebar column shrinking 320px → 280px in the broken states is the
layout-stability half of the report.

Edit mode (the `<textarea>` branch) never blew out — a textarea's width
is not content-derived and Chrome's UA sheet already gives it
`overflow-wrap: break-word` — and is measured here to confirm it.

vitest runs `environment: 'node'` with no mount harness, and even a DOM
harness computes no layout, so the browser measurement cannot live in CI.
`tests/unit/filesPanelPreviewClip.spec.js` is the source-structure guard
for it (the `agentDetailDeepLink.spec.js` shape): it pins both classes,
pins that the root still clips — the premise that makes `min-w-0`
necessary — and pins the `PortalFilePreview.vue` precedent so the two
surfaces cannot silently disagree about wrap-vs-scroll. Each assertion
was proven load-bearing by reverting its class and watching it fail.

Related to #2666

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
Measured the snapshot the build actually produces:

    Name                          Min Disk Size    Size
    trinity-v0-9-5-rc2-20260903   80               9.01 GiB

The content is 9 GiB. The 80 GB floor came entirely from the build droplet's
own disk, and DigitalOcean does not let a snapshot deploy to anything smaller —
"you must select a disk size equal to or larger than the Droplet used to create
the snapshot." So every customer was being pushed onto an 80 GB boot disk, and
paying for it, to hold 9 GiB. On the v5 plans, where the boot disk is sized
independently of RAM, that is the difference between choosing a disk and being
told one.

s-1vcpu-2gb (50 GB) rather than DigitalOcean's recommended $6 s-1vcpu-1gb
(25 GB): the 9.01 GiB is their COMPRESSED stored size, while Docker's overlay2
tree on the build droplet is uncompressed — actual build-time use is nearer
18-20 GB before Ubuntu and the apt caches. Too tight against 25 GB to commit
without a build proving it, and a build droplet that exhausts its disk fails
only after pulling every image. 50 GB is also what three of DigitalOcean's own
catalog apps build on (openclaw, jellyfin, craftcms).

25 GB is probably reachable and would match their guidance exactly; one
experimental build settles it, and it is not worth blocking the listing on.
Marked with a ponytail: note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sj48trT3XmyaSus4GUtXB
…asure it (#2281)

A droplet booted from the 2026-09-10 snapshot reports:

    /dev/vda1  77G  8.8G  68G  12% /

8.8 GB, with everything installed and Trinity running. The comment justifying
s-1vcpu-2gb assumed Docker's uncompressed overlay2 tree would reach 18-20 GB and
called 25 GB too tight. That was a guess dressed as a reason, and it was wrong by
more than a factor of two.

Value unchanged — 50 GB still works and is what three of DigitalOcean's own
catalog apps build on — but the stated reason is now the measurement rather than
the guess, and the ponytail note names what is actually left: 25 GB is settled on
disk, and the only open question is whether 1 GB of RAM survives `apt
full-upgrade` and `docker pull`. Their own exa-24-04 builds on that size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sj48trT3XmyaSus4GUtXB
…olliding (#2281)

Two builds of v0.9.5-rc2, identical except for build_size, settle what was
guesswork:

    s-1vcpu-2gb (50 GB)   14m38s   img_check 8/0   snapshot min disk 50
    s-1vcpu-1gb (25 GB)   11m53s   img_check 8/0   snapshot min disk 25

Both open questions about the $6 droplet are now measured. Disk: a droplet from
the snapshot reports 8.8 GB used with everything installed and Trinity running,
against 25 GB available. RAM: a full build on 1 GB completed with no OOM and no
disk exhaustion, through `apt full-upgrade` and the agent base image pull, the
two places 1 GB would have bitten. It is also faster than the size it replaces,
and DigitalOcean's own exa-24-04 builds here.

That is AC 2 of #2281 met literally rather than in substance, and it takes the
floor a customer must deploy onto from 80 GB down to 25 GB. DigitalOcean does not
allow a snapshot to deploy to a smaller disk than it was built on, so this value
is the listing's real hardware constraint: on the v5 line, where boot disk is
sized independently of RAM, it decides whether an operator picks their disk or is
handed one.

Also: `snapshot_name` gains minute resolution. Those two builds produced two
snapshots named `trinity-v0-9-5-rc2-20260910`, distinguishable only by ID and min
disk size, while the Vendor Portal's "select a system image" step picks by what it
shows you. Submitting the wrong image costs a review cycle and fails silently,
since both are valid Trinity snapshots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sj48trT3XmyaSus4GUtXB
The PR deletes docs/planning/PULL_PILOT_946_SOAK.md and already removed
this same bullet from MESSAGE_ENVELOPE_SCHEMA.md; the copy in
agent-to-agent-collaboration.md's "Reference docs" list was missed
because the PR's link verification scoped to docs/planning/, not
docs/memory/.

Verified: all five files this PR deletes now have zero live (non-archive)
references.

merge-train: mechanical, per the merge-train note on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CswDvc38w7EheWhbbUD4So
…ers (#2281)

Found by booting a droplet from the 25 GB snapshot and looking at the Console:
it renders `trinity-25gb-verify login:` and nothing else. `passwd -S root`
reports `L` — locked.

DigitalOcean's create page requires either a password or an SSH key, and the
choice decides which route to the MOTD exists:

  password auth   root has a password, Droplet -> Console works, no SSH needed
  SSH key auth    root is LOCKED, the Console cannot accept a login at all;
                  the customer must ssh root@<ip>

`listing.md` and this README both described the Console as *the* way to get the
admin password, unconditionally. For every customer who picks key auth — which
DigitalOcean's own create page nudges toward — that instruction leads to a prompt
they cannot satisfy, for the one credential without which the listing is unusable.

Not fixable by setting a root password: `img_check.sh` scores "User root has no
password set" as a PASS condition, so an image that sets one fails review. Both
routes reach the MOTD, so documenting both is the fix.

The MOTD itself is correct and was never the problem — it renders the password,
the URL, the HTTPS status and the support line as designed. Also adds the
`cat /etc/trinity/admin-credentials` fallback for re-reading it later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sj48trT3XmyaSus4GUtXB
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants