fleetd #474: ConfigRef.reload() runs the charter tool-surface gate too #475

Closed
agent wants to merge 4 commits from worker/474-charter-check-on-reload-f54a55-17 into main
Member

Closes fleetd #474.

The gap

Fleetd.main refuses to start when a launch charter names an MCP tool the server does not register (#469, CharterToolSurface.assertChartersNameOnlyRegisteredTools, called right after cfg.validateAll()). ConfigRef.reload() only ran fresh.validateAll(), which never looks at what a charter's text names (only that the key is a role wire name and the text is non-blank) — so the identical bad charter that refuses startup could be installed into a running daemon through a reload, and the next spawned member for that role would get a charter naming a tool that does not exist.

The fix

CharterToolSurface stays in dev.ltms.fleet.mcp on purpose (config loads before the MCP server exists, so FleetConfig/dev.ltms.fleet.config must not depend on mcp) — that design constraint was not revisited.

  • ConfigRef gains a Consumer<FleetConfig> extraValidation field and a 3-arg constructor. reload() calls extraValidation.accept(fresh) right after fresh.validateAll(), inside the same try/catch, so either failure refuses the whole reload and keeps the running config. The existing 2-arg constructor (every other caller/test) gets a no-op consumer, so nothing else changes behaviour.
  • Fleetd.java gets a new package-private adapter, Fleetd.assertChartersNameOnlyRegisteredTools(FleetConfig), wrapping the existing CharterToolSurface call. The startup call site now goes through this method, and Fleetd.main wires Fleetd::assertChartersNameOnlyRegisteredTools into ConfigRef's constructor as extraValidation — so both the startup and reload call sites run the exact same method and can never drift apart.

Acceptance criteria (ticket #474)

  1. A reload naming an unregistered tool is refused, keeps the running config, message names both the charter key and the unknown tool — ConfigRefTest.aReloadRefusesACharterNamingAnUnregisteredTool and FleetdConfigRefCharterToolSurfaceWiringTest.reloadRefusesACharterNamingAnUnregisteredToolThroughFleetdsOwnWiring.
  2. A test drives the reload path itself, not a unit call to the check — both tests above call ConfigRef.reload(), never CharterToolSurface.assertChartersNameOnlyRegisteredTools directly.
  3. Positive case: a reload with a valid charter is still accepted — ConfigRefTest.aReloadAcceptsACharterNamingOnlyRegisteredTools and the wiring test's reloadAcceptsACharterNamingOnlyRegisteredToolsThroughFleetdsOwnWiring.
  4. Deleting the new call site fails a test by name — verified by hand: temporarily removed extraValidation.accept(fresh); from ConfigRef.reload() and ran mvn test -Dtest='ConfigRefTest,FleetdConfigRefCharterToolSurfaceWiringTest'. Result: Tests run: 30, Failures: 2 — ConfigRefTest.aReloadRefusesACharterNamingAnUnregisteredTool and FleetdConfigRefCharterToolSurfaceWiringTest.reloadRefusesACharterNamingAnUnregisteredToolThroughFleetdsOwnWiring both failed by name (expected: <false> but was: <true>). Restored the line afterward; git diff --stat on ConfigRef.java shows a clean 40-line addition with no leftover artifact.
  5. FleetConfig still has no dependency on mcp — checked with grep -n "import dev.ltms.fleet.mcp" fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java (no match) and the same for ConfigRef.java (no match — the two dev.ltms.fleet.mcp.CharterToolSurface mentions there are inside {@code} javadoc text, not imports). ConfigRef.java's only new import is java.util.function.Consumer.
  6. Full-suite count before/after, unpiped, real exit code — see below.

Build

Ran mvn clean install in fleetd/, output captured to a file (never piped), exit code read separately from the file, not assumed from a truncated tail.

  • Before my edits (branched from 435e022): Tests run: 1618, Failures: 0, Errors: 0, Skipped: 0 — BUILD SUCCESS — EXIT_CODE=0.
  • After my edits (final run): Tests run: 1622, Failures: 0, Errors: 0, Skipped: 0 — BUILD SUCCESS — EXIT_CODE=0.
  • Delta is exactly the 4 new tests added (2 in ConfigRefTest, 2 in FleetdConfigRefCharterToolSurfaceWiringTest).

Extra criterion 7 (does a refused reload keep the running config?)

Yes. extraValidation.accept(fresh) runs inside reload()'s existing try/catch, the same one fresh.validateAll() already uses — an exception there is caught, logged, and Outcome.failed(msg) is returned without ever calling current.set(fresh). Both new "refuses" tests assert assertSame(before, ref.get()) / assertSame(before, config.get()) after the refusal, and both pass in the full suite above.

Files changed

  • fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java — extraValidation field, 3-arg constructor, reload() call site, class-doc addition.
  • fleetd/src/main/java/dev/ltms/fleet/Fleetd.java — new assertChartersNameOnlyRegisteredTools(FleetConfig) adapter; startup call site and ConfigRef construction both route through it.
  • fleetd/src/main/java/dev/ltms/fleet/mcp/CharterToolSurface.java — class-doc update describing the second call site.
  • fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTest.java — two new tests (refuse/accept), using a locally-built Consumer<FleetConfig> equivalent to Fleetd's (package-private, not visible from dev.ltms.fleet.config).
  • fleetd/src/test/java/dev/ltms/fleet/FleetdConfigRefCharterToolSurfaceWiringTest.java (new) — same two proofs, through the literal Fleetd::assertChartersNameOnlyRegisteredTools reference production uses.

Not run / caveats

  • Could not run the wiki-sync check (wiki/ is uninitialized in this worker's worktree — git submodule status shows a leading -). Not claiming it passed.
  • No fleetd.yaml in this worktree (gitignored) — reproduced the live config shape entirely in @TempDir fixtures, per the existing ConfigRefTest/FleetdStartupValidationTest pattern.
  • Scope was exactly ticket #474; nothing else touched.
Closes fleetd #474. ## The gap `Fleetd.main` refuses to start when a launch charter names an MCP tool the server does not register (#469, `CharterToolSurface.assertChartersNameOnlyRegisteredTools`, called right after `cfg.validateAll()`). `ConfigRef.reload()` only ran `fresh.validateAll()`, which never looks at what a charter's *text* names (only that the key is a role wire name and the text is non-blank) — so the identical bad charter that refuses startup could be installed into a running daemon through a reload, and the next spawned member for that role would get a charter naming a tool that does not exist. ## The fix `CharterToolSurface` stays in `dev.ltms.fleet.mcp` on purpose (config loads before the MCP server exists, so `FleetConfig`/`dev.ltms.fleet.config` must not depend on `mcp`) — that design constraint was not revisited. - `ConfigRef` gains a `Consumer<FleetConfig> extraValidation` field and a 3-arg constructor. `reload()` calls `extraValidation.accept(fresh)` right after `fresh.validateAll()`, inside the *same* try/catch, so either failure refuses the whole reload and keeps the running config. The existing 2-arg constructor (every other caller/test) gets a no-op consumer, so nothing else changes behaviour. - `Fleetd.java` gets a new package-private adapter, `Fleetd.assertChartersNameOnlyRegisteredTools(FleetConfig)`, wrapping the existing `CharterToolSurface` call. The startup call site now goes through this method, and `Fleetd.main` wires `Fleetd::assertChartersNameOnlyRegisteredTools` into `ConfigRef`'s constructor as `extraValidation` — so both the startup and reload call sites run the exact same method and can never drift apart. ## Acceptance criteria (ticket #474) 1. **A reload naming an unregistered tool is refused, keeps the running config, message names both the charter key and the unknown tool** — `ConfigRefTest.aReloadRefusesACharterNamingAnUnregisteredTool` and `FleetdConfigRefCharterToolSurfaceWiringTest.reloadRefusesACharterNamingAnUnregisteredToolThroughFleetdsOwnWiring`. 2. **A test drives the reload path itself, not a unit call to the check** — both tests above call `ConfigRef.reload()`, never `CharterToolSurface.assertChartersNameOnlyRegisteredTools` directly. 3. **Positive case: a reload with a valid charter is still accepted** — `ConfigRefTest.aReloadAcceptsACharterNamingOnlyRegisteredTools` and the wiring test's `reloadAcceptsACharterNamingOnlyRegisteredToolsThroughFleetdsOwnWiring`. 4. **Deleting the new call site fails a test by name** — verified by hand: temporarily removed `extraValidation.accept(fresh);` from `ConfigRef.reload()` and ran `mvn test -Dtest='ConfigRefTest,FleetdConfigRefCharterToolSurfaceWiringTest'`. Result: `Tests run: 30, Failures: 2` — `ConfigRefTest.aReloadRefusesACharterNamingAnUnregisteredTool` and `FleetdConfigRefCharterToolSurfaceWiringTest.reloadRefusesACharterNamingAnUnregisteredToolThroughFleetdsOwnWiring` both failed by name (`expected: <false> but was: <true>`). Restored the line afterward; `git diff --stat` on `ConfigRef.java` shows a clean 40-line addition with no leftover artifact. 5. **`FleetConfig` still has no dependency on `mcp`** — checked with `grep -n "import dev.ltms.fleet.mcp" fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java` (no match) and the same for `ConfigRef.java` (no match — the two `dev.ltms.fleet.mcp.CharterToolSurface` mentions there are inside `{@code}` javadoc text, not imports). `ConfigRef.java`'s only new import is `java.util.function.Consumer`. 6. **Full-suite count before/after, unpiped, real exit code** — see below. ## Build Ran `mvn clean install` in `fleetd/`, output captured to a file (never piped), exit code read separately from the file, not assumed from a truncated tail. - **Before my edits** (branched from `435e022`): `Tests run: 1618, Failures: 0, Errors: 0, Skipped: 0` — `BUILD SUCCESS` — `EXIT_CODE=0`. - **After my edits** (final run): `Tests run: 1622, Failures: 0, Errors: 0, Skipped: 0` — `BUILD SUCCESS` — `EXIT_CODE=0`. - Delta is exactly the 4 new tests added (2 in `ConfigRefTest`, 2 in `FleetdConfigRefCharterToolSurfaceWiringTest`). ## Extra criterion 7 (does a refused reload keep the running config?) Yes. `extraValidation.accept(fresh)` runs inside `reload()`'s existing try/catch, the same one `fresh.validateAll()` already uses — an exception there is caught, logged, and `Outcome.failed(msg)` is returned without ever calling `current.set(fresh)`. Both new "refuses" tests assert `assertSame(before, ref.get())` / `assertSame(before, config.get())` after the refusal, and both pass in the full suite above. ## Files changed - `fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java` — `extraValidation` field, 3-arg constructor, `reload()` call site, class-doc addition. - `fleetd/src/main/java/dev/ltms/fleet/Fleetd.java` — new `assertChartersNameOnlyRegisteredTools(FleetConfig)` adapter; startup call site and `ConfigRef` construction both route through it. - `fleetd/src/main/java/dev/ltms/fleet/mcp/CharterToolSurface.java` — class-doc update describing the second call site. - `fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTest.java` — two new tests (refuse/accept), using a locally-built `Consumer<FleetConfig>` equivalent to `Fleetd`'s (package-private, not visible from `dev.ltms.fleet.config`). - `fleetd/src/test/java/dev/ltms/fleet/FleetdConfigRefCharterToolSurfaceWiringTest.java` (new) — same two proofs, through the literal `Fleetd::assertChartersNameOnlyRegisteredTools` reference production uses. ## Not run / caveats - Could not run the wiki-sync check (`wiki/` is uninitialized in this worker's worktree — `git submodule status` shows a leading `-`). Not claiming it passed. - No `fleetd.yaml` in this worktree (gitignored) — reproduced the live config shape entirely in `@TempDir` fixtures, per the existing `ConfigRefTest`/`FleetdStartupValidationTest` pattern. - Scope was exactly ticket #474; nothing else touched.
agent added 2 commits 2026-09-10 15:26:23 +02:00
memberSkills: copied skill folders into every provisioned worktree's
.claude/skills/ and stopped there. Claude Code reads that directory
natively; opencode never does. So the feature was INERT for opencode
members rather than broken: the copy succeeded, the files were correct,
and nothing ever read them. No test failed because there was nothing to
fail - the feature worked at the only layer it implemented.

An opencode member's only channel for static guidance is the instructions[]
array in its generated config. OpenCodeLauncher now adds each seeded
skill's SKILL.md there. A folder with no SKILL.md is never delivered and
the log names it.

The second half is an ordering hazard fleet01 found by reading, and that I
then measured. Three writers append to instructions[]: the role charter,
the seeded skills, and the IDE rules. The charter used putArray
(CREATE-OR-REPLACE) while the other two used withArray (get-or-create).
That was safe only because the charter ran first against an empty array -
an undeclared constraint that nothing tested. Measured on the earlier
merge: making the skills writer use putArray left 1603 tests green while
silently deleting the charter entry, so an opencode member would launch
with no role contract at all. Worse than the bug being fixed, and
invisible.

All three writers now use withArray, and three tests pin the array's
CONTENTS (never its size - a size assertion passes when putArray swaps two
entries for two others) across the combinations that matter:
charter-only, charter+ide, charter+skills+ide.

Credit where it is due: fleet01 supplied the mechanism by reading the code
and I supplied the consequence by mutating it. Their later point is the one
worth keeping - the reason nothing caught that mutation is narrower than
"the ordering was undeclared". NOTHING ASSERTED THE CHARTER REACHES AN
OPENCODE MEMBER AT ALL. That assertion should have existed since the
charter feature shipped. #393's worker inherited that hole rather than
creating it, and the mutation only exposed it. It is now
instructionsArrayHoldsExactlyTheCharterWhenNothingElseWritesToIt.

One honest residue: the charter writer's own idiom cannot be pinned.
Flipping it back to putArray leaves the suite green, and always will,
because it runs first against an empty array where the two idioms are
equivalent. The edit removes an undeclared constraint for the next person
to add a writer; it is not a change any test can detect. The comment in
the source said two named tests cover it - that was wrong, and I corrected
it rather than leaving a false claim beside the code.

What this does NOT do: opencode has no equivalent of Claude Code's Skill
tool, so the content is static system-prompt text present from spawn, not
something a member can invoke by name. This closes the DELIVERY gap and
cannot close the ACTIVATION gap. That residue is opencode's design.

Numbers and my own mutation battery are on the ticket, measured on this
merge commit.
fleetd #474: ConfigRef.reload() runs the charter tool-surface gate too
CI / build (pull_request) Successful in 1m25s
CI / contract (pull_request) Successful in 1m39s
97e4c1d658
A charter naming an MCP tool the server does not register refused Fleetd.main
at startup but slipped through ConfigRef.reload(), because reload() only ran
FleetConfig.validateAll(), which never looks at what a charter's text names.

CharterToolSurface stays in the mcp package (config must not depend on it), so
ConfigRef now accepts the check as a Consumer<FleetConfig> extraValidation,
run inside reload()'s same try/catch as validateAll(). Fleetd.main wires a new
package-private adapter, Fleetd.assertChartersNameOnlyRegisteredTools, into
both the startup call site and ConfigRef's constructor, so the two call sites
can never check different things.

Tests: ConfigRefTest (reload refuses/accepts, via a locally-built equivalent
consumer since Fleetd's method is package-private to dev.ltms.fleet) and the
new FleetdConfigRefCharterToolSurfaceWiringTest (same proof through the exact
Fleetd::assertChartersNameOnlyRegisteredTools reference production uses).
Verified deleting the new extraValidation.accept(fresh) call site fails both
new "refuses" tests by name.
Owner

Landed as 4466ee0, and I am leaving this PR open — I have sent you a follow-up on the same branch, so a new commit belongs here rather than in a new PR.

Cherry-picked, not merged

Your commit 97e4c1d sits on 435e022, which is not an ancestor of main. That is my fault, not yours: I reset and rewrote that commit's message for a different ticket while you were already branched from it. Merging your branch would have added a second merge commit for work already on main, and git diff origin/main...HEAD warned multiple merge bases with a diffstat naming files you never touched. That is also why this PR reports 9 changed files and mergeable: false.

git cherry-pick -x 97e4c1d gave the same content with no duplicated history: 5 files, 292 insertions, 4 deletions — identical to your own diffstat. The rule I took from it: once a worker is spawned, its base commit is published.

Your report checked

Your delta claim was right. 1629 green on main before this, 1633 after — exactly the 4 tests you added. Your before-number was 1618 because your base was the stranded commit, so the difference is #393 and #473 landing in between, not a discrepancy.

Every caveat you listed was honest, including the two you could not run. Thank you for saying so rather than inventing a pass. I checked criterion 5 myself: grep -c '^import dev.ltms.fleet.mcp' across dev/ltms/fleet/config/ returns 0 files. The constraint holds.

My battery, on the pushed tree fbb9b58

Five cells, each a full mvn -B clean test.

cell mutation result
CONTROL 1 none 1633, 0 failures, BUILD SUCCESS
M1 delete extraValidation.accept(fresh); KILLED — ConfigRefTest.aReloadRefusesACharterNamingAnUnregisteredTool, FleetdConfigRefCharterToolSurfaceWiringTest.reloadRefusesACharterNamingAnUnregisteredToolThroughFleetdsOwnWiring
M3 3-arg constructor ignores its argument, stores a no-op KILLED — same 2
M4 current.set(fresh) before validating KILLED — 6, including both assertSame cases
M5 accept(fresh) moved outside reload()'s try/catch KILLED — 2 errors
M2 Fleetd.java:154 back to the 2-arg constructor SURVIVED — 1633 green

M1 and M3 are the pair I care about: one deletes the call, the other keeps the call and kills the value. Both die. M5 confirms the "same try/catch" claim in your PR body is real — moving the line out turns a refusal into a thrown exception, and reload()'s javadoc says it never throws.

M2 is the gap, and it is a test gap, not a defect — your production code is right. Details and the follow-up brief are in the message I just sent you. Short version: both of your new tests build their own ConfigRef with the method reference, so nothing reads what main actually wires, and one line reverts the live gate with the whole suite green.

CI has not run on 4466ee0 yet; I will read it rather than assume it.

**Landed as `4466ee0`, and I am leaving this PR open** — I have sent you a follow-up on the same branch, so a new commit belongs here rather than in a new PR. ## Cherry-picked, not merged Your commit `97e4c1d` sits on `435e022`, which is **not** an ancestor of `main`. That is my fault, not yours: I reset and rewrote that commit's message for a different ticket while you were already branched from it. Merging your branch would have added a second merge commit for work already on main, and `git diff origin/main...HEAD` warned `multiple merge bases` with a diffstat naming files you never touched. That is also why this PR reports 9 changed files and `mergeable: false`. `git cherry-pick -x 97e4c1d` gave the same content with no duplicated history: 5 files, 292 insertions, 4 deletions — identical to your own diffstat. The rule I took from it: **once a worker is spawned, its base commit is published.** ## Your report checked Your delta claim was right. `1629` green on main before this, `1633` after — exactly the 4 tests you added. Your before-number was 1618 because your base was the stranded commit, so the difference is #393 and #473 landing in between, not a discrepancy. Every caveat you listed was honest, including the two you could not run. Thank you for saying so rather than inventing a pass. I checked criterion 5 myself: `grep -c '^import dev.ltms.fleet.mcp' ` across `dev/ltms/fleet/config/` returns 0 files. The constraint holds. ## My battery, on the pushed tree `fbb9b58` Five cells, each a full `mvn -B clean test`. | cell | mutation | result | |---|---|---| | CONTROL 1 | none | **1633, 0 failures, BUILD SUCCESS** | | M1 | delete `extraValidation.accept(fresh);` | **KILLED** — `ConfigRefTest.aReloadRefusesACharterNamingAnUnregisteredTool`, `FleetdConfigRefCharterToolSurfaceWiringTest.reloadRefusesACharterNamingAnUnregisteredToolThroughFleetdsOwnWiring` | | M3 | 3-arg constructor ignores its argument, stores a no-op | **KILLED** — same 2 | | M4 | `current.set(fresh)` before validating | **KILLED** — 6, including both `assertSame` cases | | M5 | `accept(fresh)` moved outside `reload()`'s try/catch | **KILLED** — 2 errors | | M2 | `Fleetd.java:154` back to the 2-arg constructor | **SURVIVED — 1633 green** | M1 and M3 are the pair I care about: one deletes the call, the other keeps the call and kills the value. Both die. M5 confirms the "same try/catch" claim in your PR body is real — moving the line out turns a refusal into a thrown exception, and `reload()`'s javadoc says it never throws. **M2 is the gap, and it is a test gap, not a defect** — your production code is right. Details and the follow-up brief are in the message I just sent you. Short version: both of your new tests build their own `ConfigRef` with the method reference, so nothing reads what `main` actually wires, and one line reverts the live gate with the whole suite green. CI has not run on `4466ee0` yet; I will read it rather than assume it.
Owner

Merged. Your work is on origin/main as 4466ee0, and the follow-up that closes the one gap my battery found is 49a404d (PR #476). git rev-list --count origin/main..HEAD = 0.

Closing this PR by hand. Two things about how it landed, because both are worth knowing for next time.

I cherry-picked rather than merged. Your branch was based on a commit that is not an ancestor of main, so git diff origin/main...HEAD warned multiple merge bases and a merge would have added a second merge commit for work already on main. git cherry-pick -x 97e4c1d was the fix, and it kept your authorship and the original commit reference. Nothing was lost. For your next unit: branch from a freshly fetched origin/main, which is exactly what you did on #476 without being told, and it made that merge clean.

The battery on the merged commit. Five mutations on tree fbb9b58, each a full build against your 1633-test baseline. M1 (delete extraValidation.accept(fresh)), M3 (the constructor ignores its argument), M4 (current.set(fresh) before validating) and M5 (accept outside the try/catch) were all killed, several by your own two tests, by name. M2 — reverting Fleetd.java:154 to the two-argument constructor — survived with 1633 green and BUILD SUCCESS. ConfigRef was right, the adapter was right, and both your tests kept passing because each builds its own ConfigRef. Only what the daemon wires changed, and with that one line reverted this ticket was undone silently.

That is the gap #476 closes, and the reason is structural rather than a mistake of yours: extracting a check into a well-tested helper moves the untested surface up, into the one line that chooses to call it. Full detail is on #476 and on #474.

One thing you got right that I want named: your FleetdConfigRefCharterToolSurfaceWiringTest javadoc says plainly that Fleetd.main cannot be driven that far in a unit test. That was true, and saying it is what let me see what was missing instead of assuming the call site was covered. A test that documents its own limit is worth more than one that quietly implies more than it proves.

Merged. Your work is on `origin/main` as `4466ee0`, and the follow-up that closes the one gap my battery found is `49a404d` (PR #476). `git rev-list --count origin/main..HEAD` = 0. Closing this PR by hand. Two things about how it landed, because both are worth knowing for next time. **I cherry-picked rather than merged.** Your branch was based on a commit that is not an ancestor of `main`, so `git diff origin/main...HEAD` warned `multiple merge bases` and a merge would have added a second merge commit for work already on main. `git cherry-pick -x 97e4c1d` was the fix, and it kept your authorship and the original commit reference. Nothing was lost. For your next unit: branch from a freshly fetched `origin/main`, which is exactly what you did on #476 without being told, and it made that merge clean. **The battery on the merged commit.** Five mutations on tree `fbb9b58`, each a full build against your 1633-test baseline. M1 (delete `extraValidation.accept(fresh)`), M3 (the constructor ignores its argument), M4 (`current.set(fresh)` before validating) and M5 (`accept` outside the try/catch) were all killed, several by your own two tests, by name. M2 — reverting `Fleetd.java:154` to the two-argument constructor — **survived with 1633 green and BUILD SUCCESS**. `ConfigRef` was right, the adapter was right, and both your tests kept passing because each builds its own `ConfigRef`. Only what the daemon wires changed, and with that one line reverted this ticket was undone silently. That is the gap #476 closes, and the reason is structural rather than a mistake of yours: extracting a check into a well-tested helper moves the untested surface **up**, into the one line that chooses to call it. Full detail is on #476 and on #474. One thing you got right that I want named: your `FleetdConfigRefCharterToolSurfaceWiringTest` javadoc says plainly that `Fleetd.main` cannot be driven that far in a unit test. That was true, and saying it is what let me see what was missing instead of assuming the call site was covered. A test that documents its own limit is worth more than one that quietly implies more than it proves.
ltms closed this pull request 2026-09-10 23:16:05 +02:00
Some checks are pending
CI / build (pull_request) Successful in 1m25s
CI / contract (pull_request) Successful in 1m39s

Pull request closed

Sign in to join this conversation.