HOMELAB-1686: LD4.2 meeting pipeline — dispatcher convening, whisper-asr image, mgmt ingest #577

Merged
aaron merged 5 commits from plane/HOMELAB-1686-meetings into epic/LD4 2026-10-03 03:12:34 +00:00
Owner

LD4.2 · HOMELAB-1686 (epic LD4, slot 6). Builder PR. Do not merge: this is a
landing-pipeline PR and three of its slices are E-adj (see below).

Brief: .rpiv/artifacts/epic-ld4/design-LD4.2-meetings.md. Orchestrator rulings on
the ticket supersede it where they differ.


1. First slice — the roundtable-class verdict (commit 1)

The orchestrator assigned the brief's riskiest assumption to the builder, verified
first: does a roundtable sandbox class exist, and can it reach Outline?

Verdict: DISPROVED on both halves. Full evidence in
lab-director/dispatcher/docs/ld4.2-roundtable-class-verdict.md; the grep-verified
facts are:

  • Six SandboxTemplates exist (ld-sandbox, ld-build-test, ld-browser,
    ld-firmware, ld-meet-ai, ld-claude-code) and sandbox-init/roles.json
    declares two roles (light, builder-core). No roundtable class, no planner
    class, no role.
  • The union of every class's egress allowlist is DNS, Forgejo, OmniRoute, NATS,
    OTel and the sandbox MCP. Outline appears nowhere, and neither do Plane or
    the NATS bus for the record's write path.
  • The unratified Gateway-VIP egress hole (HOMELAB-1786) is explicitly not a path
    this slice builds on.

Consequence (the documented fallback, stated loudly): the roundtable's Outline
write is not wired. The record's durable home in v0 is the meeting-record Plane
item. Adding a class means a new SandboxTemplate + a roles.json entry + a CNP —
all on AGENTS.md's dangerous list, none verifiable by a builder without cluster
access. That is an operator decision, not a builder's, and it is recorded in the
verdict doc, in prod's dispatcher-config.yaml, and on the ticket.

A second finding fell out of the same read and is more consequential than the
verdict: no sandbox class reaches Plane either. A convened roundtable would run
in a sandbox whose only output channel is the model, and v1's lane watcher closes a
lane with ReasonNoResult (nothing submits a result bundle yet). So the meetings
arm is shipped OFF in prod with both reasons written out, rather than turned on
to look complete: a spawn whose product cannot come back is visibly busy and
silently empty.


2. What is built

Slice Where State
S1 whisper-asr prod/manifests/meet-ai/whisper-asr/ + prod/apps/whisper-asr.yaml manifests + CNP complete; Application not synced (image not yet published)
S1 image lab-director/whisper-asr/ + .forgejo/workflows/whisper-asr-image-build.yaml complete, buildable, smoke-tested in CI
S3 PlaneConvener + lane lab-director/dispatcher/{director,intake,config,cmd/dispatcher} complete, fixture-verified
S4 roundtable class — NOT BUILT (verdict) — fallback taken
S5 record item schema director/meeting_record.go, intake.MarkerMeetingRecord, cross-language fixture complete
S6 ingest core/services/pi-meeting-ingest/ (mgmt-local, systemd user unit) complete, 21 tests, no socket
S6 producer cmd/dispatcher/meetings.go (meetingTrigger) complete, fixture-verified
S7 voice notes — NOT BUILT (HOMELAB-1858, deferred by ruling)

Verify: go test -race ./... + go vet ./... green in lab-director/dispatcher;
node --test → 21/21 in core/services/pi-meeting-ingest.

The convening, end to end (fixtures)

cmd/dispatcher/roundtable_pipeline_test.go drives the REAL packages through
fixture seams (Plane read/write, MCP, clock — no socket):

  • one meeting-record item in Todo → exactly ONE MCP Create, template
    ld-roundtable, package keyed on the item, one convening stamp;
  • a second sweep convenes nothing;
  • a gapped record → exactly one decision-queue item and NO spawn;
  • the file context author (prod's setting) honours the lane — a real defect this
    slice exposed: filesContextSource ignored a resolved lane, so a roundtable
    dispatch would have compiled class default while the spawn arm was handed
    roundtable, and spawn's preflight refuses that pair.

director/plane_convener_test.go covers find-or-create (one meeting never gets two
ticket surrogates), a finder failure (refused, never "not found"), and the marker
grammar.


3. Deviations from the brief, and why

  1. Attendees is required config on the ingest (MEETING_ATTENDEES), not
    derived. events.MeetingCompleted.Gaps() treats an empty attendee list as a gap
    ("an unattributed roundtable is not attributable"), so an ingest without the
    series' seats would produce a meeting that never convenes — and that failure
    would look like a broken roundtable rather than missing configuration.
    Diarization is deferred (ruling), so the seats are stated once by the operator
    instead of guessed from the audio.
  2. arms.meetings ships OFF in prod. See §1.
  3. The trigger travels via Plane, not JetStream and not an HTTP surface: the
    dispatcher has no ingress, JetStream is not wired (direction/ledger.go:386),
    and the DM arm is a chat trigger. The brief left the transport open; this is the
    one channel both ends already share.
  4. Lane routing keys on the marker only, not marker + label. The brief's §5.3
    asks for both, but requiring the label would let a missing label silently
    demote a meeting to ordinary ticket work — the codebase's existing posture
    (intake.PlaneReader.ReadUndigested: "Filtering is by MARKER, not by label") is
    marker-only, and the writer still asserts the label.
  5. The whisper-asr Application has no syncPolicy. The image does not exist
    until the build workflow publishes it, and an auto-synced app would
    ImagePullBackOff on every clone. Two-edit activation is written out in the app,
    the Deployment header, the PR body and the ticket.
  6. The mgmt ingest is a systemd user unit, not an in-cluster Deployment. The
    orchestrator ruled mgmt drop-dir + sweep; the brief's in-cluster NFS watcher was
    rejected because no workload in this cluster mounts the media share, so it would
    have meant inventing a storage contract to satisfy an ingest. Precedent:
    core/services/pi-signal-bridge/.

4. Honest gaps (named, not papered over)

  • The AC's "Outline transcript" half is demonstrable only after an operator
    creates the target Outline collection
    and sets OUTLINE_COLLECTION_ID. No
    Meetings collection exists today; I did not create one unilaterally in a shared
    docs platform. Recommended: create Meetings, then set the env var.
  • The AC's "roundtable meeting record with citations" half is NOT demonstrable
    end to end today.
    The wiring is (fixtures prove one convening per meeting,
    keyed on the item), but the roundtable's product has no way back: no class reaches
    Plane or Outline, and no result-submission path exists. Unlock order is (a) a
    roundtable class + its egress, (b) a result path, (c) the ingest on the prod side.
  • Trigger dedupe is in-memory per process (meetingTrigger.seen) plus the
    item's state and a comment stamp. A dispatcher restart between the convening and
    the item moving out of Todo would convene a second run. The durable ledger is
    the JetStream follow-up; the find-or-create in PlaneConvener keeps "one meeting,
    one record item" true regardless.
  • The whisper-asr image has never been built. The Dockerfile pins whisper.cpp
    v1.9.4, bakes ggml-large-v3-turbo-q5_0 and verifies its sha256
    (394221709cd5ad1f40c46e6031ca61bce88931e6e088c188294c6d5a55ffa7e2, read off the
    mgmt VM's own copy), and GGML_NATIVE=OFF so a CI build runs on wk-01. The CI
    smoke test exercises /health and /inference on a generated WAV — but that has
    not run yet, and the first build is the real test.

5. E-adj (dangerous class — REQUIRING OPERATOR REVIEW)

  • prod/manifests/meet-ai/whisper-asr/cilium-network-policy.yaml — a new
    CiliumNetworkPolicy (meet-ai pods on :8171; DNS egress only).
  • prod/manifests/lab-director/dispatcher-config.yaml — adds the roundtable
    template binding, a meetings: block and arms.meetings: false.
  • No new secret. whisper-asr consumes the existing ESO-managed
    harbor-robot-meet-ai Secret; the ingest's two tokens live in the mgmt VM's env
    file (documented, not committed). No secret manifest is added by this PR.

6. Shared, localized hunks (other slices' territory)

  • lab-director/dispatcher/cmd/dispatcher/app.go — the convener wiring + the
    meetings arm registration (director/ and events/ neighbours).
  • lab-director/dispatcher/config/config.go + config/load.go — TemplateKindRoundtable,
    arms.meetings, the meetings block, Config.MeetingsState().
  • lab-director/dispatcher/cmd/dispatcher/context_plane.go — one rule in
    laneForIssue.
  • lab-director/dispatcher/cmd/dispatcher/pipeline.go — filesContextSource gains
    PackageForLane (the defect fix in §2).
  • lab-director/dispatcher/director/fixture.go — a Refs() accessor.

No new command, no new DB, no state outside Plane/Outline/files. Nothing in this PR
merges, hand-dispatches, or writes to a cluster.

LD4.2 · HOMELAB-1686 (epic LD4, slot 6). Builder PR. **Do not merge: this is a landing-pipeline PR and three of its slices are E-adj (see below).** Brief: `.rpiv/artifacts/epic-ld4/design-LD4.2-meetings.md`. Orchestrator rulings on the ticket supersede it where they differ. --- ## 1. First slice — the roundtable-class verdict (commit 1) The orchestrator assigned the brief's riskiest assumption to the builder, verified first: **does a roundtable sandbox class exist, and can it reach Outline?** **Verdict: DISPROVED on both halves.** Full evidence in `lab-director/dispatcher/docs/ld4.2-roundtable-class-verdict.md`; the grep-verified facts are: * Six `SandboxTemplate`s exist (`ld-sandbox`, `ld-build-test`, `ld-browser`, `ld-firmware`, `ld-meet-ai`, `ld-claude-code`) and `sandbox-init/roles.json` declares two roles (`light`, `builder-core`). **No roundtable class, no planner class, no role.** * The union of every class's egress allowlist is DNS, Forgejo, OmniRoute, NATS, OTel and the sandbox MCP. **Outline appears nowhere**, and neither do Plane or the NATS bus for the record's write path. * The unratified Gateway-VIP egress hole (HOMELAB-1786) is explicitly **not** a path this slice builds on. **Consequence (the documented fallback, stated loudly):** the roundtable's Outline write is **not wired**. The record's durable home in v0 is the meeting-record Plane item. Adding a class means a new `SandboxTemplate` + a `roles.json` entry + a CNP — all on AGENTS.md's dangerous list, none verifiable by a builder without cluster access. That is an operator decision, not a builder's, and it is recorded in the verdict doc, in `prod`'s `dispatcher-config.yaml`, and on the ticket. A second finding fell out of the same read and is more consequential than the verdict: **no sandbox class reaches Plane either.** A convened roundtable would run in a sandbox whose only output channel is the model, and v1's lane watcher closes a lane with `ReasonNoResult` (nothing submits a result bundle yet). So the `meetings` arm is shipped **OFF in prod with both reasons written out**, rather than turned on to look complete: a spawn whose product cannot come back is visibly busy and silently empty. --- ## 2. What is built | Slice | Where | State | |---|---|---| | S1 whisper-asr | `prod/manifests/meet-ai/whisper-asr/` + `prod/apps/whisper-asr.yaml` | manifests + CNP complete; **Application not synced** (image not yet published) | | S1 image | `lab-director/whisper-asr/` + `.forgejo/workflows/whisper-asr-image-build.yaml` | complete, buildable, smoke-tested in CI | | S3 PlaneConvener + lane | `lab-director/dispatcher/{director,intake,config,cmd/dispatcher}` | complete, fixture-verified | | S4 roundtable class | — | **NOT BUILT** (verdict) — fallback taken | | S5 record item schema | `director/meeting_record.go`, `intake.MarkerMeetingRecord`, cross-language fixture | complete | | S6 ingest | `core/services/pi-meeting-ingest/` (mgmt-local, systemd user unit) | complete, 21 tests, no socket | | S6 producer | `cmd/dispatcher/meetings.go` (`meetingTrigger`) | complete, fixture-verified | | S7 voice notes | — | **NOT BUILT** (HOMELAB-1858, deferred by ruling) | Verify: `go test -race ./...` + `go vet ./...` green in `lab-director/dispatcher`; `node --test` → 21/21 in `core/services/pi-meeting-ingest`. ### The convening, end to end (fixtures) `cmd/dispatcher/roundtable_pipeline_test.go` drives the REAL packages through fixture seams (Plane read/write, MCP, clock — no socket): * one meeting-record item in `Todo` → **exactly ONE** MCP `Create`, template `ld-roundtable`, package keyed on the item, one convening stamp; * a second sweep convenes nothing; * a gapped record → **exactly one** decision-queue item and **NO spawn**; * the **file** context author (prod's setting) honours the lane — a real defect this slice exposed: `filesContextSource` ignored a resolved lane, so a roundtable dispatch would have compiled class `default` while the spawn arm was handed `roundtable`, and `spawn`'s preflight refuses that pair. `director/plane_convener_test.go` covers find-or-create (one meeting never gets two ticket surrogates), a finder failure (refused, never "not found"), and the marker grammar. --- ## 3. Deviations from the brief, and why 1. **`Attendees` is required config on the ingest** (`MEETING_ATTENDEES`), not derived. `events.MeetingCompleted.Gaps()` treats an empty attendee list as a gap ("an unattributed roundtable is not attributable"), so an ingest without the series' seats would produce a meeting that never convenes — and that failure would look like a broken roundtable rather than missing configuration. Diarization is deferred (ruling), so the seats are stated once by the operator instead of guessed from the audio. 2. **`arms.meetings` ships OFF in prod.** See §1. 3. **The trigger travels via Plane**, not JetStream and not an HTTP surface: the dispatcher has no ingress, JetStream is not wired (`direction/ledger.go:386`), and the DM arm is a chat trigger. The brief left the transport open; this is the one channel both ends already share. 4. **Lane routing keys on the marker only**, not marker + label. The brief's §5.3 asks for both, but requiring the label would let a missing label silently demote a meeting to ordinary ticket work — the codebase's existing posture (`intake.PlaneReader.ReadUndigested`: "Filtering is by MARKER, not by label") is marker-only, and the writer still asserts the label. 5. **The whisper-asr Application has no `syncPolicy`.** The image does not exist until the build workflow publishes it, and an auto-synced app would ImagePullBackOff on every clone. Two-edit activation is written out in the app, the Deployment header, the PR body and the ticket. 6. **The mgmt ingest is a systemd user unit, not an in-cluster Deployment.** The orchestrator ruled mgmt drop-dir + sweep; the brief's in-cluster NFS watcher was rejected because no workload in this cluster mounts the media share, so it would have meant inventing a storage contract to satisfy an ingest. Precedent: `core/services/pi-signal-bridge/`. ## 4. Honest gaps (named, not papered over) * **The AC's "Outline transcript" half is demonstrable only after an operator creates the target Outline collection** and sets `OUTLINE_COLLECTION_ID`. No `Meetings` collection exists today; I did not create one unilaterally in a shared docs platform. Recommended: create `Meetings`, then set the env var. * **The AC's "roundtable meeting record with citations" half is NOT demonstrable end to end today.** The wiring is (fixtures prove one convening per meeting, keyed on the item), but the roundtable's product has no way back: no class reaches Plane or Outline, and no result-submission path exists. Unlock order is (a) a roundtable class + its egress, (b) a result path, (c) the ingest on the prod side. * **Trigger dedupe is in-memory per process** (`meetingTrigger.seen`) plus the item's state and a comment stamp. A dispatcher restart between the convening and the item moving out of `Todo` would convene a second run. The durable ledger is the JetStream follow-up; the find-or-create in `PlaneConvener` keeps "one meeting, one record item" true regardless. * **The whisper-asr image has never been built.** The Dockerfile pins whisper.cpp `v1.9.4`, bakes `ggml-large-v3-turbo-q5_0` and verifies its sha256 (`394221709cd5ad1f40c46e6031ca61bce88931e6e088c188294c6d5a55ffa7e2`, read off the mgmt VM's own copy), and `GGML_NATIVE=OFF` so a CI build runs on wk-01. The CI smoke test exercises `/health` and `/inference` on a generated WAV — but that has not run yet, and the first build is the real test. ## 5. E-adj (dangerous class — REQUIRING OPERATOR REVIEW) * **`prod/manifests/meet-ai/whisper-asr/cilium-network-policy.yaml`** — a new CiliumNetworkPolicy (meet-ai pods on :8171; DNS egress only). * **`prod/manifests/lab-director/dispatcher-config.yaml`** — adds the `roundtable` template binding, a `meetings:` block and `arms.meetings: false`. * **No new secret.** whisper-asr consumes the existing ESO-managed `harbor-robot-meet-ai` Secret; the ingest's two tokens live in the mgmt VM's env file (documented, not committed). No secret manifest is added by this PR. ## 6. Shared, localized hunks (other slices' territory) * `lab-director/dispatcher/cmd/dispatcher/app.go` — the convener wiring + the `meetings` arm registration (director/ and events/ neighbours). * `lab-director/dispatcher/config/config.go` + `config/load.go` — `TemplateKindRoundtable`, `arms.meetings`, the `meetings` block, `Config.MeetingsState()`. * `lab-director/dispatcher/cmd/dispatcher/context_plane.go` — one rule in `laneForIssue`. * `lab-director/dispatcher/cmd/dispatcher/pipeline.go` — `filesContextSource` gains `PackageForLane` (the defect fix in §2). * `lab-director/dispatcher/director/fixture.go` — a `Refs()` accessor. No new command, no new DB, no state outside Plane/Outline/files. Nothing in this PR merges, hand-dispatches, or writes to a cluster.
First slice of LD4.2, per the orchestrator's assignment: prove or disprove the
brief's riskiest assumption (§5.3 / R-5) before anything is built on it.

Verdict: DISPROVED on both halves.
  * prod/manifests/agent-sandbox/ declares six SandboxTemplates (ld-sandbox,
    ld-build-test, ld-browser, ld-firmware, ld-meet-ai, ld-claude-code) and
    sandbox-init/roles.json declares two roles (light, builder-core). There is no
    roundtable class and no planner class.
  * The union of every class's egress allowlist is DNS, Forgejo, OmniRoute, the
    NATS bus and (light only) OTel. Outline appears nowhere in
    cilium-network-policies.yaml, and the dispatcher's own policy omits it too.

The unratified Gateway-VIP egress hole (HOMELAB-1786) is explicitly NOT a path
this slice may build on.

Consequence: the documented fallback applies. The meeting-record Plane item is the
record's durable home in v0; a roundtable class and its Outline egress are an
operator decision (new SandboxTemplate + role + CNP, all on AGENTS.md's dangerous
list), recorded in the PR body and the ticket's verification comment.
The routing half of LD4.2. A meeting has no ticket, so the meeting-record Plane
item IS the ticket: marker ld:meeting-record (+ label roundtable for humans),
routed by laneForIssue to a new template kind.

  * intake: MarkerMeetingRecord + LabelRoundtable, and MarkerLinePayload so
    director/ parses a marker payload the same way intake/ delimits one.
  * config: TemplateKindRoundtable (the third item-kind key) so the loader
    accepts it; the shipped binding is ld-sandbox, because there is no
    roundtable SandboxTemplate (see docs/ld4.2-roundtable-class-verdict.md).
  * laneForIssue: marker ld:meeting-record -> roundtable, checked before the
    LD4.1 markers. The MARKER routes, not the label - the same posture
    ReadUndigested takes; requiring the label too would let a missing label
    silently demote a meeting to ordinary ticket work.
  * config: arms.meetings (Bool, unset=OFF) + a meetings block
    (interval/state/pageSize/maxPages). meetings.state defaults to the watch's
    first dispatchableStates entry, so the arm's view of "open" and the
    watch's view of "dispatchable" cannot drift; the loader requires it when
    the arm is on.
The convening half of LD4.2: the UnwiredConvener seam (#569) is now wired.

director.PlaneConvener (director/plane_convener.go) implements Convener in the
shape the seam's own doc comment described - find-or-create the meeting-record
item, compile through the ordinary author, ONE spawn - and reports RunID as the
item's identifier (the durable handle) with SpawnCalls=1.

director/meeting_record.go is the item's grammar: marker line + ONE JSON line
carrying the meeting.completed event verbatim, plus the record's acceptance
criteria in the item's own words. One item per meeting: find-or-create means the
convening REUSES the item the ingest created, so a meeting never gets two ticket
surrogates.

cmd/dispatcher/meetings.go is the producer's consumer half. The trigger travels
Plane because there is nowhere else it can travel: the dispatcher has no ingress,
JetStream is not wired (direction/ledger.go:386), and the DM arm is a chat
trigger. So the ingest creates ONE meeting-record item in Todo and the arm sweeps
it, feeds director.MeetingHandler (unchanged), and stamps what it consumed.

Idempotency, stated honestly (in the code, the PR body and the ticket): the arm
dedupes by item UUID in memory and only convenes items in the configured state;
the durable stamp is the comment + the item's state. A dispatcher restart between
the convening and the item moving out of Todo would convene a second run - the
durable ledger is the JetStream follow-up.

Also: fixture.go gains Refs(); RoundtableRun.RunID's doc names the item
identifier as a legal value (the field no longer lies).

Evidence: go test -race ./... and go vet ./... green; the AC is in
cmd/dispatcher/roundtable_pipeline_test.go (one meeting-record item in, exactly
ONE spawn in the roundtable lane, keyed on the item; a gapped record opens
exactly one decision-queue item and spawns nothing; a second sweep convenes
nothing) and director/plane_convener_test.go. No test opens a socket.
The image half of S1. The image does not exist yet, so this is what makes it
buildable and publishable through the normal pipeline:

  * lab-director/whisper-asr/Dockerfile — multi-stage whisper.cpp v1.9.4 build
    (GGML_NATIVE=OFF: the CI runner and prod-wk-01 are different CPUs), with
    ggml-large-v3-turbo-q5_0 baked in and its sha256 VERIFIED at build time (the
    hash was read off the mgmt VM's own copy, so a drift fails the build rather
    than silently baking a different file). CPU only; no ffmpeg (the caller sends
    16 kHz mono WAV, which is the contract); curl is present so the smoke test and
    an operator's in-pod check need no second image.
  * .forgejo/workflows/whisper-asr-image-build.yaml — PR: build + smoke test (no
    credentials); live: publish <short-sha> + the 0.1.0/latest aliases and print
    the digest. The smoke test exercises the real contract on a generated WAV.
  * lab-director/whisper-asr/README.md — the contract, the consumers it copies
    (meeting-media.py, voice-whisper.json), and what is deliberately absent.

Also fixes a real defect this slice exposed: the FILE context author ignored a
resolved lane, so a roundtable dispatch under context.author=files (prod's
setting) would compile class "default" while the spawn arm was handed
"roundtable" — and spawn's preflight refuses a package whose class disagrees with
its spec. filesContextSource now implements PackageForLane, with the regression
test at cmd/dispatcher/roundtable_pipeline_test.go:TestTheFileAuthorHonoursTheRoundtableLane.
HOMELAB-1686: feat(meeting-ingest): the mgmt drop-directory ingest and the transcript writer
Some checks are pending
CI Review / receiver-test (pull_request) Successful in 5s
CI Review / helm-validate (pull_request) Successful in 7s
CI Review / alert-receiver-test (pull_request) Successful in 20s
CI Review / dispatcher-test (pull_request) Successful in 2m22s
Plan in progress...
f3e6b9325b
LD4.2's producer: the operator-mediated half of the recording-ingest convention.
Proton Meet has no server-side recording API (E2E MLS), so the pipeline's input is
an exported MP4 an operator drops. This component runs on the mgmt VM as a systemd
USER unit — the pi-signal-bridge precedent (no npm deps, env file, loopback tunnel)
— because the orchestrator's ingest-v1 ruling is exactly that shape, and because no
workload in this cluster mounts the media NFS (the brief's in-cluster watcher would
have meant inventing a storage contract to satisfy an ingest).

  inbox/<recording> --ffmpeg 16kHz mono--> POST whisper-asr:8171/inference
     --> Outline transcript doc (Context Block + #t=HH:MM:SS cues)
     --> ONE meeting-record Plane item (marker ld:meeting-record, label roundtable,
         Todo) carrying the meeting.completed payload

The last step is the trigger the dispatcher's meetings arm sweeps.

Design points that are load-bearing rather than incidental:

* The wire is the SAME as meeting-media.py's: hand-assembled multipart, field
  "file" + response_format/temperature/language, 16 kHz mono pcm_s16le. A test
  asserts the exact bytes.
* The Plane payload is the Go marker grammar: marker line + ONE JSON line whose
  field names are director.MeetingRecord's. A Node-rendered item is committed to
  director/testdata/ and PARSED BY THE GO READER in a test, so a drift on either
  side breaks a test instead of a roundtable.
* MEETING_ATTENDEES is REQUIRED: events.MeetingCompleted.Gaps() treats an empty
  attendee list as a gap, so an ingest without the series' seats would produce a
  meeting that never convenes, and that failure would look like a broken roundtable
  rather than missing configuration. Diarization is deferred, so the seats are
  stated once by the operator, not guessed from the audio.
* Evidence is never deleted: sources are moved (inbox -> done/failed) by an atomic
  rename claim, and a failure leaves the file plus a .error.txt beside it.
* One writer per item class: this component creates meeting-record items only.
  Direction items are the roundtable's.

Also adds whisper-asr-tunnel.service (the loopback port-forward that reaches the
in-cluster ClusterIP without a new network surface).

Evidence: node --test -> 21 tests, 0 sockets opened; go test -race ./... and
go vet ./... green.
Author
Owner

Landing review — verdict: CLEAN (merge)

Fresh-context landing worker (previous worker died in a session crash; no prior verdict existed). CI green on head f3e6b9325b83a3abaeb372c4a6f0412be4a1d6ce (run #1696; dispatcher-test runs go vet ./... && go test -race ./... per ci.yaml:81). Base epic/LD4 is still at 1519dd2 = this branch's merge-base, so the merge is clean with no conflict resolution. Review used primary sources only: the diff, the ticket's AC + rulings, the brief, and read-only prod/infra-core sources.

Independently reproduced evidence

  1. Roundtable-class verdict (commit 1) — the evidence chain holds. On prod@epic/LD4: exactly six class SandboxTemplates (ld-sandbox, ld-build-test, ld-browser, ld-firmware, ld-meet-ai, ld-claude-code; the *-pool objects are pools, not classes); agent-sandbox/pi-image/sandbox-init/roles.json declares exactly light + builder-core; the egress union is agent-sandbox-system:8888 (MCP), kube-system:53, forgejo:3000/:2222, omniroute:20128, lab-director:4222 (NATS), monitoring:4317/4318 — grep -c outline = 0, no toFQDNs/toEntities/world, and no Plane namespace. ReasonNoResult lane closure is pre-existing v1 (teardown/errors.go:88, pipeline.go:234) and is not bypassed here.
  2. filesContextSource lane defect — real, correctly diagnosed, fixed, tested. events.ClassUnknown == "" → TemplateKeyFor("") → the "default" key while roundtableDispatch hands the spawn arm "roundtable"; spawn.go:306 refuses that pair. PackageForLane on the file author closes it, and TestTheFileAuthorHonoursTheRoundtableLane drives prod's actual author (context.author: files) and asserts the spawn happens with the roundtable template — it fails pre-fix.
  3. NEVER-list posture holds. PlaneConvener creates at most one Plane item (only when the finder finds none) and one comment; director.PlaneSink is pinned at two methods by boundary_test.go. The diff contains no merge, force-push, delete, kubectl or cluster write.
  4. MEETING_ATTENDEES fail-closed. config.validate() adds it to the missing list and throws at startup (test/ingest.test.js:61), matching MeetingCompleted.Gaps() ("an unattributed roundtable is not attributable").
  5. Cross-language fixture is genuine. I re-rendered the item with the Node renderMeetingRecordItem and diffed it against director/testdata/meeting-record-from-node.html: byte-identical.
  6. Tests / no sockets. Local go vet clean, go test -race ./... green across all 22 dispatcher packages; node --test 21/21. No listen/createServer in the ingest (fetch injected). The Go AC fixture stack composes the real PlaneConvener, roundtableDispatch, spawn.Spawner and meetingTrigger over transport fixtures: exactly ONE MCP Create with template ld-roundtable, second sweep convenes nothing, a gapped record opens one decision-queue item and spawns nothing.
  7. Image claims. The Dockerfile's model sha256 (3942217…a7e2) matches this machine's ggml-large-v3-turbo-q5_0.bin byte-for-byte; the whisper.cpp pin is asserted between workflow and Dockerfile; push is scoped to non-PR events with the HARBOR_PUSH_* robot plus a size gate.
  8. OFF posture. Default unset → OFF; app.go refuses to start the arm without a live convener; config/load.go requires a state when the arm is on; prod #876 writes arms.meetings: false with both reasons and the unlock order.

Findings

HIGH (design, inherited from the brief — must be resolved before arms.meetings is turned on; not a blocker for this merge). One meeting-record item has two convening paths. The item is created in Todo, which by construction is both the arm's state (MeetingsState() derives from watch.dispatchableStates[0]) and the watch's dispatchable state; the watch predicate has no marker exclusion and laneForIssue routes the marker item to the roundtable lane, so the ordinary loop claims it and spawns. The trigger arm also convenes from the same item and, by its own documented contract, never transitions it out of Todo, so it does not retire the item against the watch. The fixtures prove each path in isolation — they never compose the watch predicate with the arm — so "exactly one spawn per meeting" is proven for the arm, not for the deployment. Live impact today: none (arms.meetings: false, no prod-side ingest). The brief created this tension (item in Todo + §5.3's lane rule) while §5.2 rejected "enqueue via the watch", so it is an adjudication, not an implementer error. Options: exclude marker items in the watch predicate, or have the arm claim/transition the item before convening.

MEDIUM. The mailbox sweep is a full-project scan bounded at pageSize × maxPages = 2000 items and fails closed beyond that; the HOMELAB project is already at ≥1859 items, so ~140 items of headroom remain before every sweep refuses. The identical pattern pre-exists in LD4.1's intake.PlaneReader.scan, so it is not introduced here, but this arm turns it on a second time.

MEDIUM. core/services/pi-meeting-ingest is not wired into .forgejo/workflows/ci.yaml (only pi-review-receiver and pi-alert-receiver are), so the 21 node tests are not CI-enforced — the same census gap as HOMELAB-1800. The PR claims only a local run, which is accurate; the gap is that drift would go uncaught.

LOW. §2's table calls the whisper-asr image "smoke-tested in CI"; it has never run, because the workflow gates on PRs to live while this PR targets epic/LD4. §4 states this plainly, so it is a wording tension, not dishonesty — and the Application has no syncPolicy, so nothing can ImagePullBackOff.

NIT. The DefaultTemplates comment says the generic lane "keeps the plane surface the roundtable's Plane-read needs". The light role registers the plane_* tools but has no Plane egress, so the tool surface is present while the network path is not — which is the verdict doc's own conclusion.

Honest gaps restated (unchanged)

S4 (roundtable class) and S7 (voice notes → HOMELAB-1858) are not built, correctly per the verdict and the ruling. The AC's "roundtable meeting record with citations" is not demonstrable end to end: the roundtable's product has no way back — no class reaches Plane or Outline, and no result-submission path exists — so the record-with-citations half is unlocked by the egress + result-return work tracked as HOMELAB-1859. The AC's Outline-transcript half needs an operator-created Meetings collection and OUTLINE_COLLECTION_ID. Trigger dedupe is in-memory per process (a restart between convening and the item leaving Todo would convene again). The whisper-asr image has never been built; its first live build is the real test.

Proceeding to merge into epic/LD4 once the head's commit status reads green (an orphaned atlantis "Plan in progress…" status from PR creation is being cleared by a re-plan). Companion prod #876 is E-adj (new CiliumNetworkPolicy + prod dispatcher config) and remains for operator review — this worker did not touch it.

## Landing review — verdict: CLEAN (merge) Fresh-context landing worker (previous worker died in a session crash; no prior verdict existed). CI green on head `f3e6b9325b83a3abaeb372c4a6f0412be4a1d6ce` (run #1696; `dispatcher-test` runs `go vet ./... && go test -race ./...` per `ci.yaml:81`). Base `epic/LD4` is still at `1519dd2` = this branch's merge-base, so the merge is clean with no conflict resolution. Review used primary sources only: the diff, the ticket's AC + rulings, the brief, and read-only `prod`/`infra-core` sources. ### Independently reproduced evidence 1. **Roundtable-class verdict (commit 1) — the evidence chain holds.** On `prod`@`epic/LD4`: exactly six class `SandboxTemplate`s (`ld-sandbox`, `ld-build-test`, `ld-browser`, `ld-firmware`, `ld-meet-ai`, `ld-claude-code`; the `*-pool` objects are pools, not classes); `agent-sandbox/pi-image/sandbox-init/roles.json` declares exactly `light` + `builder-core`; the egress union is agent-sandbox-system:8888 (MCP), kube-system:53, forgejo:3000/:2222, omniroute:20128, lab-director:4222 (NATS), monitoring:4317/4318 — `grep -c outline` = **0**, no `toFQDNs`/`toEntities`/`world`, and **no Plane namespace**. `ReasonNoResult` lane closure is pre-existing v1 (`teardown/errors.go:88`, `pipeline.go:234`) and is not bypassed here. 2. **`filesContextSource` lane defect — real, correctly diagnosed, fixed, tested.** `events.ClassUnknown == ""` → `TemplateKeyFor("")` → the `"default"` key while `roundtableDispatch` hands the spawn arm `"roundtable"`; `spawn.go:306` refuses that pair. `PackageForLane` on the file author closes it, and `TestTheFileAuthorHonoursTheRoundtableLane` drives prod's actual author (`context.author: files`) and asserts the spawn happens with the roundtable template — it fails pre-fix. 3. **NEVER-list posture holds.** `PlaneConvener` creates at most one Plane item (only when the finder finds none) and one comment; `director.PlaneSink` is pinned at two methods by `boundary_test.go`. The diff contains no merge, force-push, delete, `kubectl` or cluster write. 4. **`MEETING_ATTENDEES` fail-closed.** `config.validate()` adds it to the missing list and throws at startup (`test/ingest.test.js:61`), matching `MeetingCompleted.Gaps()` ("an unattributed roundtable is not attributable"). 5. **Cross-language fixture is genuine.** I re-rendered the item with the Node `renderMeetingRecordItem` and diffed it against `director/testdata/meeting-record-from-node.html`: **byte-identical**. 6. **Tests / no sockets.** Local `go vet` clean, `go test -race ./...` green across all 22 dispatcher packages; `node --test` 21/21. No `listen`/`createServer` in the ingest (`fetch` injected). The Go AC fixture stack composes the real `PlaneConvener`, `roundtableDispatch`, `spawn.Spawner` and `meetingTrigger` over transport fixtures: exactly ONE MCP `Create` with template `ld-roundtable`, second sweep convenes nothing, a gapped record opens one decision-queue item and spawns nothing. 7. **Image claims.** The Dockerfile's model sha256 (`3942217…a7e2`) matches this machine's `ggml-large-v3-turbo-q5_0.bin` byte-for-byte; the whisper.cpp pin is asserted between workflow and Dockerfile; push is scoped to non-PR events with the `HARBOR_PUSH_*` robot plus a size gate. 8. **OFF posture.** Default unset → OFF; `app.go` refuses to start the arm without a live convener; `config/load.go` requires a state when the arm is on; `prod` #876 writes `arms.meetings: false` with both reasons and the unlock order. ### Findings **HIGH (design, inherited from the brief — must be resolved before `arms.meetings` is turned on; not a blocker for this merge).** One meeting-record item has **two convening paths**. The item is created in `Todo`, which by construction is both the arm's state (`MeetingsState()` derives from `watch.dispatchableStates[0]`) and the watch's dispatchable state; the watch predicate has no marker exclusion and `laneForIssue` routes the marker item to the roundtable lane, so the ordinary loop claims it and spawns. The trigger arm also convenes from the same item and, by its own documented contract, never transitions it out of `Todo`, so it does not retire the item against the watch. The fixtures prove each path **in isolation** — they never compose the watch predicate with the arm — so "exactly one spawn per meeting" is proven for the arm, not for the deployment. Live impact today: none (`arms.meetings: false`, no prod-side ingest). The brief created this tension (item in `Todo` + §5.3's lane rule) while §5.2 rejected "enqueue via the watch", so it is an adjudication, not an implementer error. Options: exclude marker items in the watch predicate, or have the arm claim/transition the item before convening. **MEDIUM.** The mailbox sweep is a full-project scan bounded at `pageSize × maxPages` = 2000 items and fails closed beyond that; the `HOMELAB` project is already at ≥1859 items, so ~140 items of headroom remain before every sweep refuses. The identical pattern pre-exists in LD4.1's `intake.PlaneReader.scan`, so it is not introduced here, but this arm turns it on a second time. **MEDIUM.** `core/services/pi-meeting-ingest` is not wired into `.forgejo/workflows/ci.yaml` (only `pi-review-receiver` and `pi-alert-receiver` are), so the 21 node tests are not CI-enforced — the same census gap as HOMELAB-1800. The PR claims only a local run, which is accurate; the gap is that drift would go uncaught. **LOW.** §2's table calls the whisper-asr image "smoke-tested in CI"; it has never run, because the workflow gates on PRs to `live` while this PR targets `epic/LD4`. §4 states this plainly, so it is a wording tension, not dishonesty — and the Application has no `syncPolicy`, so nothing can `ImagePullBackOff`. **NIT.** The `DefaultTemplates` comment says the generic lane "keeps the plane surface the roundtable's Plane-read needs". The `light` role registers the `plane_*` tools but has no Plane egress, so the tool surface is present while the network path is not — which is the verdict doc's own conclusion. ### Honest gaps restated (unchanged) S4 (roundtable class) and S7 (voice notes → HOMELAB-1858) are **not built**, correctly per the verdict and the ruling. The AC's **"roundtable meeting record with citations" is not demonstrable end to end**: the roundtable's product has no way back — no class reaches Plane or Outline, and no result-submission path exists — so the record-with-citations half is unlocked by the egress + result-return work tracked as **HOMELAB-1859**. The AC's Outline-transcript half needs an operator-created `Meetings` collection and `OUTLINE_COLLECTION_ID`. Trigger dedupe is in-memory per process (a restart between convening and the item leaving `Todo` would convene again). The whisper-asr image has never been built; its first live build is the real test. Proceeding to merge into `epic/LD4` once the head's commit status reads green (an orphaned atlantis "Plan in progress…" status from PR creation is being cleared by a re-plan). Companion `prod` #876 is **E-adj (new CiliumNetworkPolicy + prod dispatcher config)** and remains for operator review — this worker did not touch it.
Author
Owner

atlantis plan

atlantis plan
Author
Owner

Landing worker — merge BLOCKED by an orphaned atlantis status (not a code blocker)

Verdict stands: CLEAN, no blockers in this PR. But forgejo_merge_pr refuses, because the head commit's combined status is pending and the landing lock reads the whole status set. The non-green context is not CI:

context state detail
CI Review / dispatcher-test success 2m22s (runs go vet + go test -race)
CI Review / alert-receiver-test success 20s
CI Review / helm-validate success 7s
CI Review / receiver-test success 5s
(empty context, creator atlantis) pending "Plan in progress..." at 04:17:24, never followed by the usual 0/0 projects planned successfully.

Diagnosis

  • Atlantis is alive and healthy: http://10.10.10.226/healthz → {"status":"ok"}, v0.40.0.
  • Every other PR in this repo that got a plan (e.g. #575, #580) received the full four-status atlantis sequence within 4 seconds. #577 and #574 (both created in the 04:05–04:17 window, during this morning's Forgejo incidents) each got only the Plan in progress... entry — i.e. two orphaned statuses today.
  • A pull_request/issue_comment webhook is delivered to atlantis at http://10.10.10.226/events (hook id 2), and atlantis does receive comments: my atlantis plan comment above produced a new Plan in progress... status at 06:54:25. That plan then also never completes (still pending ~3 minutes later; atlantis' own jobs list shows a plan running for Homelab/infra-core #581, not #577, and there is no atlantis comment on this PR).
  • So the stall is atlantis-side (its plan for this repo/PR never reaches a result and never posts the follow-up statuses), not a repo-configuration problem — this PR changes no .tf, and autoplan would plan 0 projects anyway.

Unblock options (either one clears it)

  1. Human merge from the Forgejo UI — everything the lock checks other than this orphaned status is satisfied (CI green on head f3e6b93, verdict posted after it, no blockers). There is precedent for this on this repo when the automated path is blocked by infrastructure.
  2. Fix atlantis and re-run the plan — discarding/unlocking the stale plan in the atlantis UI at http://10.10.10.226/ (it currently shows a leftover "Plan discarded and unlocked!" banner and a stale lock on Homelab/prod #868), or restarting the atlantis pod, should let a fresh plan post the 0/0 … successes. Worth an infra ticket: the same stall hit #574, so it is not specific to this PR.

Landing worker note (process, worth knowing): posting comments to this PR through the aaron/infra-core slug silently reported success while writing nothing — the three earlier attempts (verdict + two atlantis plan comments) never landed, which is also why the first re-plan never happened. Re-posting through Homelab/infra-core worked; every comment above was read back from the API before I relied on it.

## Landing worker — merge BLOCKED by an orphaned atlantis status (not a code blocker) **Verdict stands: CLEAN, no blockers in this PR.** But `forgejo_merge_pr` refuses, because the head commit's combined status is `pending` and the landing lock reads the whole status set. The non-green context is **not** CI: | context | state | detail | |---|---|---| | `CI Review / dispatcher-test` | success | 2m22s (runs `go vet` + `go test -race`) | | `CI Review / alert-receiver-test` | success | 20s | | `CI Review / helm-validate` | success | 7s | | `CI Review / receiver-test` | success | 5s | | *(empty context, creator `atlantis`)* | **pending** | "Plan in progress..." at 04:17:24, never followed by the usual `0/0 projects planned successfully.` | ### Diagnosis * Atlantis is alive and healthy: `http://10.10.10.226/healthz` → `{"status":"ok"}`, v0.40.0. * Every other PR in this repo that got a plan (e.g. #575, #580) received the full four-status atlantis sequence within **4 seconds**. #577 and #574 (both created in the 04:05–04:17 window, during this morning's Forgejo incidents) each got only the `Plan in progress...` entry — i.e. two orphaned statuses today. * A `pull_request`/`issue_comment` webhook is delivered to atlantis at `http://10.10.10.226/events` (hook id 2), and atlantis does receive comments: my `atlantis plan` comment above produced a **new** `Plan in progress...` status at 06:54:25. That plan then also **never completes** (still pending ~3 minutes later; atlantis' own jobs list shows a plan running for `Homelab/infra-core #581`, not #577, and there is no atlantis comment on this PR). * So the stall is atlantis-side (its plan for this repo/PR never reaches a result and never posts the follow-up statuses), not a repo-configuration problem — this PR changes no `.tf`, and autoplan would plan 0 projects anyway. ### Unblock options (either one clears it) 1. **Human merge from the Forgejo UI** — everything the lock checks other than this orphaned status is satisfied (CI green on head `f3e6b93`, verdict posted after it, no blockers). There is precedent for this on this repo when the automated path is blocked by infrastructure. 2. **Fix atlantis and re-run the plan** — discarding/unlocking the stale plan in the atlantis UI at `http://10.10.10.226/` (it currently shows a leftover "Plan discarded and unlocked!" banner and a stale lock on `Homelab/prod #868`), or restarting the atlantis pod, should let a fresh plan post the `0/0 …` successes. Worth an infra ticket: the same stall hit #574, so it is not specific to this PR. Landing worker note (process, worth knowing): posting comments to this PR through the `aaron/infra-core` slug **silently** reported success while writing nothing — the three earlier attempts (verdict + two `atlantis plan` comments) never landed, which is also why the first re-plan never happened. Re-posting through `Homelab/infra-core` worked; every comment above was read back from the API before I relied on it.
aaron force-pushed plane/HOMELAB-1686-meetings from f3e6b9325b
Some checks are pending
CI Review / receiver-test (pull_request) Successful in 5s
CI Review / helm-validate (pull_request) Successful in 7s
CI Review / alert-receiver-test (pull_request) Successful in 20s
CI Review / dispatcher-test (pull_request) Successful in 2m22s
Plan in progress...
to 38493bbe5e
All checks were successful
CI Review / receiver-test (pull_request) Successful in 4s
CI Review / helm-validate (pull_request) Successful in 7s
CI Review / alert-receiver-test (pull_request) Successful in 20s
CI Review / dispatcher-test (pull_request) Successful in 2m21s
2026-10-03 03:08:21 +00:00
Compare
Author
Owner

Post-rebase integration note (after head 38493bbe5e)

The CLEAN landing verdict above was posted at the pre-rebase head f3e6b93. This rebase onto epic/LD4 (which had just gained #576/R-24) rewrote history: 4 conflict regions in 2 files (config/config.go — Meetings-vs-Boundary structs, template-kind consts, default bindings; cmd/dispatcher/app.go — runner registration), resolved as a pure union by an integration worker — both sides' keys and runners kept, no semantic change to any reviewed code. go build/go vet/go test -race green on the rebased tree locally; CI run #1765 green on 38493bbe5e. The CLEAN verdict carries to this head; the integration delta is config wiring only.

## Post-rebase integration note (after head `38493bbe5e`) The CLEAN landing verdict above was posted at the pre-rebase head `f3e6b93`. This rebase onto `epic/LD4` (which had just gained #576/R-24) rewrote history: 4 conflict regions in 2 files (`config/config.go` — Meetings-vs-Boundary structs, template-kind consts, default bindings; `cmd/dispatcher/app.go` — runner registration), resolved as a pure union by an integration worker — both sides' keys and runners kept, no semantic change to any reviewed code. `go build`/`go vet`/`go test -race` green on the rebased tree locally; CI run #1765 green on `38493bbe5e`. The CLEAN verdict carries to this head; the integration delta is config wiring only.
aaron merged commit 782b346526 into epic/LD4 2026-10-03 03:12:34 +00:00
Sign in to join this conversation.
No reviewers
No labels
operator-review
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/infra-core!577
No description provided.