Skip to content

feat(performance): executor performance as a first-class read surface, plus an 18-item backlog burndown - #226

Merged
cardosofede merged 45 commits into
mainfrom
feat/burndown-improvements
Sep 9, 2026
Merged

cardosofede merged 45 commits into
mainfrom
feat/burndown-improvements

Conversation

@cardosofede

@cardosofede cardosofede commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

What this is

Two things on one branch: executor performance becomes a first-class read surface, and the improvements backlog is burned down to empty — 18 items closed against a test suite that, at the start, could not actually run.


Part 1 — Executor performance as a read surface

Before this branch, only controllers had a performance history. An executor's numbers lived in memory and were lost on restart: reap a live executor after a crash and its ExecutorRecord was terminated as SYSTEM_CLEANUP carrying creation-time zeros, so /executors/performance booked real PnL at 0. There was no way to chart an executor at all.

Now:

  • Every live executor is snapshotted on an interval into its own table, and completion writes a terminal row in the same transaction as the ExecutorRecord update (behind a SAVEPOINT, so a failing snapshot INSERT can't lose the completion). A restart's reap now adopts the last snapshot's PnL, fees and filled amount instead of zeros.
  • Both populations are served from one pair of routes — GET /performance/history and GET /performance/latest — in one normalized row shape. A consumer writes seriesFor(scope) and latestFor(scope) once instead of branching on controller-vs-executor; the branch is a subject= query parameter, so it stays a parameter to the client too.
  • A closed executor's latest row is its terminal row, carrying is_terminal and close_type — "its final value" and "its current value" are the same query, with no join back to executors.
  • Wire compatibility is absolute. /bot-orchestration/controller-performance-history and -latest are untouched and byte-identical, and the new controller subject goes through the exact same orchestrator methods, so old and new cannot drift. Nothing has to migrate before it wants to.

Two new settings, both defaulting to today's behaviour: PERFORMANCE_EXECUTOR_SNAPSHOT_INTERVAL (60s — finer than the controller dump because executors are short-lived; at a 5-minute grain a three-minute position executor gets one point) and PERFORMANCE_RETENTION_DAYS (0 = keep everything forever, so an upgrade never starts deleting an operator's history).

The design doc is in features/done/FEAT-001-executor-performance-snapshots.md, including the deviations from the original design and why.


Part 2 — The backlog burndown

Start here: the test suite was lying

pytest test/ reported 82 failed, 557 passed. 81 of those were not assertions — they were:

async def functions are not natively supported.

asyncio_mode was configured nowhere in the tree (no pytest.ini, no conftest.py, no [tool.pytest.ini_options]), and pytest/pytest-asyncio were not declared dependencies, so a fresh conda env create produced an environment that could not run test/ at all. Five test files were written expecting auto mode; every async test in them was collected and then refused.

The consequence is the part that matters: a genuine regression was sitting inside that wall of red, indistinguishable from the noise. CORR-118 — an untyped lp_provider silently resolving to a router pool — had a guard test that was already failing and nobody could see it.

So the first two commits fix the instrument before using it:

  • ARCH-116 — declare asyncio_mode = "auto" and the test deps in-tree. 81 failures vanish.
  • CORR-118 — fix the one real regression that was hiding behind them.

Suite: 82 failed / 557 passed → 798 passed, 0 failed. Net +241 tests, every one of them from the items below.

The 18 items

Each item was implemented in isolation, then verified against the integrated tree before the next one started — build, full suite, and flake8/isort on the touched files. Any item that broke the tree would have been reverted and marked blocked. None were: 18 closed, 0 blocked, 0 reverted.

Correctness

Item Commit
CORR-118 459bf48 An untyped lp_provider is refused at config load instead of silently becoming a router pool
CORR-101 6e2e251 POST /backtesting/run reports failure as 500 and timeout as 504, instead of HTTP 200 with an error body
CORR-102 8f85852 A transient RuntimeError no longer permanently kills a market-data subscription — only the send_json is guarded, so a fetch blip is logged and retried
CORR-104 5657e38 gas_token repaired on CLMM liquidity rows left NULL/UNKNOWN by the old chain map, plus an idempotent backfill script
CORR-106 b4723cf stop-and-archive's silent returns now mark the bot run errored, instead of leaving it neither archived nor errored
CORR-107 951b4e9 The in-flight book is no longer truncated at startup by a hardcoded limit(1000)
CORR-111 1d23303 A database outage in get_performance_report raises instead of reporting a zeroed report as if it were real
CORR-113 e8a8c3f A non-numeric update_interval answers with a WebSocket error frame instead of crashing the handler

Performance

Item Commit
PERF-105 bee217c Closing a CLMM position no longer holds a pooled DB connection across its 2-second propagation sleep
PERF-108 5e75ce1 reconcile_active_orders: one batched SELECT and a single conditional flush, not one of each per order
PERF-110 bc02753 get_executor_stats: 7 statements → 2, return shape unchanged
PERF-114 bafaddc The Gateway availability guard answers from a 2s-TTL ping cache, invalidated on connection failure; the 503 now carries Retry-After
PERF-112 d080507 Backtest workers share a bounded, range-exact on-disk candle cache instead of re-downloading per run

Architecture and readability

Item Commit
ARCH-116 f532cf9 The suite is runnable from a fresh checkout
ARCH-109 8e7e841 AMM and archived-bots routes persist through a service — grep get_session_context routers/ is now empty
ARCH-103 6868769 The Gateway poller writes through the CLMM/swap services via one shared build_position_row()
READ-115 1477625 Container removal is gated on real API ownership rather than a name prefix
READ-117 ce19104 The .env template documents all five undocumented settings groups, with a test pinning it against config.py

Three findings worth a reviewer's attention

1. The docker prefix guard was inverted, not just vestigial (READ-115). create_hummingbot_instance passes name=instance_name verbatim, so the hummingbot- check rejected every container the API creates — while accepting exactly the infrastructure containers (hummingbot-api, hummingbot-postgres, hummingbot-broker, hummingbot-tailscale) and then trying to archive a bots/instances/ directory they never had. It is now an ownership check: a container is removable only if it owns the directory the endpoint archives. That also closed an incidental path-traversal hole in how that path was built.

2. The CLMM row shape was reconciled to the union, not the intersection (ARCH-103). The poller and the open route wrote different column sets for the same table. Dropping the poller's four extra columns would have been the smaller diff, but bin IDs and pending fees have no other reader — so both paths now write the union through one builder: NULL for what a path cannot know, 0 only where 0 is the fact. Two narrow value changes ride along: percentage is computed on Decimal on both paths, and a degenerate upper_price == 0 yields NULL rather than -1 on the discovery path.

3. The candle cache was deliberately built to not undo CORR-060 (PERF-112). The obvious implementation of "stop re-downloading candles" is to share the engine — which is exactly the shared-mutable-state bug CORR-060 closed. What crosses runs here is candle data only: no engine, no data provider, no controller, each reader unpickling its own copy. The cache is bounded three independent ways (LRU count, TTL, and exact-range keys so a wider or shifted window misses rather than serving short), with a per-key single flight so "download once" holds when BACKTESTING_MAX_CONCURRENT is raised. test_backtest_concurrency.py, test_backtest_offloading.py and test_backtest_storage.py pass unmodified.


Earlier on the branch

Correctness and safety: stop accepting API credentials in the WebSocket query string (they end up in access logs and proxy history); run each backtest in its own killable worker process; stop concurrent callers orphaning duplicate candle feeds; persist a completion whose creation row is not there yet; validate controller config names read back from the script YAML; read the EVM transaction id everywhere from one parser.

Performance: Sharpe ratio from aggregates rather than every PnL row; the in-flight order book in one SELECT when syncing; a bounded MQTT log dedup cache that is not rescanned per message.

Architecture: the CLMM and swap routes get a service to persist through; the gateway availability 503 becomes a route dependency rather than body code; the executor push loops are driven by one polling loop with configurable interval bounds; stop-and-archive addresses the bot by one name; the config-map endpoint exposes config field prompts.


Known follow-ups (found during the sweep, deliberately not fixed here)

  1. BotRunRepository.update_bot_run_stopped matches only RUNNING/CREATED rows, but stop-and-archive has already set the row to STOPPED by the time it runs — so every error-message update in that function, including the ones CORR-106 adds here, can silently no-op against a real database. This partly undercuts CORR-106 and is the most urgent of the three.
  2. setup.sh writes GATEWAY_URL=http://localhost:15888 while config.py defaults to https:// per SEC-048. The new drift test does not catch it — it only checks defaults on commented lines.
  3. Nothing runs this suite in CI. .github/workflows/ contains only the Docker buildx job, which is precisely why 82 failures went unnoticed. Now that the suite is green and runnable from a clean checkout, a CI job is finally meaningful — and is the single highest-value follow-up on this list.

Testing

798 passed, 0 failed, verified on the integrated tree after every single item — not once at the end. flake8 --max-line-length=130 and isort --check-only clean on every touched file; the repo's pre-commit gate passed on every commit with no --no-verify.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne

@greptile-apps

greptile-apps Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 3/5

The PR is not yet safe to merge because sampled history can terminate early and non-positive limits can bypass or inconsistently apply result caps.

Summary

  • Adds periodic and terminal executor snapshots, restart recovery, retention, and normalized history/latest APIs.
  • Optimizes executor statistics, backtesting, order reconciliation, Gateway access, and WebSocket polling.
  • Expands configuration documentation and enables the asynchronous test suite with substantial regression coverage.

Diagram

sequenceDiagram
    participant E as ExecutorService
    participant DB as Performance repositories
    participant API as Performance routes
    participant C as Client
    E->>DB: Persist periodic or terminal snapshot
    C->>API: "GET /performance/history?subject=executor"
    API->>DB: Query filtered snapshot window
    DB-->>API: Sampled rows, cursor, has_more
    API-->>C: Normalized performance response
    C->>API: "GET /performance/latest?subject=controller|executor"
    API->>DB: Query latest rows
    DB-->>API: Latest normalized values
    API-->>C: Subject-independent response shape
Loading

Comment on lines +256 to +260
sampled = self._sample_by_interval(history, interval_minutes, self.grain_minutes)

has_more = len(sampled) > limit if limit else False
if has_more:
sampled = sampled[:limit]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Sampling breaks pagination termination

When snapshots are missed, delayed, or unevenly spaced, the fixed over-fetch multiplier can yield at most limit sampled rows even though additional matching rows remain, causing has_more=false and next_cursor=null to silently truncate performance history.

Knowledge Base Used: Database models and repositories

Comment thread routers/performance.py
start_time: Optional[str] = Query(default=None, description="ISO 8601 start of the window"),
end_time: Optional[str] = Query(default=None, description="ISO 8601 end of the window"),
interval: str = Query(default="5m", pattern="^(1m|5m|15m|30m|1h|4h|12h|1d)$"),
limit: int = Query(default=100, le=1000),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Invalid limits bypass result caps

When a client supplies zero or a negative limit, the value reaches implementations that handle it inconsistently: zero disables the executor latest SQL cap and makes executor history fetch a default-sized page, while controller latest returns no rows; negative values can also produce invalid or backend-dependent SQL limits.

@cardosofede cardosofede changed the title feat(performance): executor performance as a first-class read surface, plus a backlog burndown feat(performance): executor performance as a first-class read surface, plus an 18-item backlog burndown Sep 1, 2026
@greptile-apps

greptile-apps Bot commented Sep 1, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

cardosofede added a commit to hummingbot/condor that referenced this pull request Sep 1, 2026
`GET /performance/history` serves controllers and executors in one row
shape (hummingbot/hummingbot-api#226). This is the Condor side of it: a
raw-call fetcher, the wire model, the proxy route and a capability probe.

The call is raw rather than through the SDK because `hummingbot-api-client`
is pinned to a released PyPI version and has no `performance` router — the
design's alternative of adding one upstream-of-the-pin is not available for
a version pin at a package index. It uses the idiom `condor.backtesting`
already established: reach for the router's session and base_url, degrade
for a client shape that exposes neither.

Three failures are kept apart, because the browser draws something
different for each. A 404 means this API predates the route — the ordinary
case for every server on the published image — so it answers 200 with
`supported: false` and the client falls back. A 400 is the caller's own
mistake (a filter aimed at the wrong population) and is forwarded as one;
reported as an offline server it would route the browser to a fallback and
hide the bug. No answer at all stays `server_online: false`.

The probe is cached with the other per-server data at a half-hour TTL, so
it costs one request per server rather than one per chart, and it
distinguishes "asked, and it is not there" from "could not ask" — a server
that was merely down must not have a fallback pinned to it for the whole
TTL after it comes back.

Two mappings are read, never re-derived: `cum_fees_quote` stays None for a
controller because `PerformanceReport` has no fees field and unknown is not
zero, and a `POSITION_HOLD` close keeps its PnL unrealized because the
position went to `position_holds` and counting it twice is the bug the
upstream mapping avoids.

The existing controller-performance routes are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxmWTzjHe55kZ6eSCfELT8
@david-hummingbot

Copy link
Copy Markdown
Contributor

Two findings from verifying this branch against a live Condor client

Both found while exercising the new /performance surface end to end. The first should be fixed before merge; the second needs a decision about which side owns it.


1. The executor sampler drops whole executors, not just points

GET /performance/history?subject=executor thins its series with one global timestamp cursor and no grouping by executor. Asked for any interval coarser than the 60-second write grain, it silently returns a subset of the fleet — with HTTP 200 and nothing indicating anything is missing.

Site: database/repositories/executor_performance_repository.py:42
Introduced: 1c09a4b — the route is new in this branch, so it ships with the defect unless fixed here.

Reproduction

Three executors, five snapshot rows, all inside a five-minute span:

INSERT INTO executor_performance_snapshots
  (timestamp, executor_id, executor_type, account_name, connector_name,
   trading_pair, controller_id, status, close_type, is_terminal,
   net_pnl_quote, net_pnl_pct, cum_fees_quote, filled_amount_quote)
VALUES
  (now() - interval '5 min', 'exec-A', 'position_executor', 'acct', 'binance', 'BTC-USDT', 'ctrl-1', 'RUNNING',    NULL,            false,  1.50,  0.0015, 0.20, 1000),
  (now() - interval '4 min', 'exec-A', 'position_executor', 'acct', 'binance', 'BTC-USDT', 'ctrl-1', 'RUNNING',    NULL,            false,  3.25,  0.0032, 0.40, 2000),
  (now() - interval '3 min', 'exec-A', 'position_executor', 'acct', 'binance', 'BTC-USDT', 'ctrl-1', 'TERMINATED', 'TAKE_PROFIT',   true,   4.75,  0.0047, 0.55, 2500),
  (now() - interval '2 min', 'exec-B', 'position_executor', 'acct', 'binance', 'ETH-USDT', 'ctrl-1', 'RUNNING',    NULL,            false, -0.80, -0.0008, 0.15,  900),
  (now() - interval '1 min', 'exec-C', 'lp_executor',       'acct', 'meteora', 'SOL-USDC', 'ctrl-2', 'TERMINATED', 'POSITION_HOLD', true,   2.00,  0.0020, 0.30, 1500);

Then read the route at each interval:

interval HTTP rows executors pairs represented
1m 200 5 3 of 3 BTC-USDT, ETH-USDT, SOL-USDC
5m — the default 200 1 1 of 3 SOL-USDC only
15m 200 1 1 of 3 SOL-USDC only
1h 200 1 1 of 3 SOL-USDC only

At the default interval, two executors and two trading pairs are absent from a 200 response. pagination.has_more is false, and the echoed interval reports what was asked for.

Cause

@staticmethod
def _sample_by_interval(history, interval_minutes, grain_minutes):
    if not history or interval_minutes <= grain_minutes:
        return history

    sampled = []
    last_sampled_time = None          # one cursor for the whole result set

    for item in history:
        item_time = datetime.fromisoformat(item["timestamp"].replace('Z','+00:00'))
        if last_sampled_time is None:
            sampled.append(item)
            last_sampled_time = item_time
        else:
            time_diff = (last_sampled_time - item_time).total_seconds() / 60
            if time_diff >= interval_minutes:    # no executor_id in this decision
                sampled.append(item)
                last_sampled_time = item_time

    return sampled

Because the cursor is global, the interval acts as a rate limit on the merged series rather than on each executor's own series. The executor that happens to own the newest row in a window survives; the rest become indistinguishable from executors that never reported.

The collision rate follows from the write cadence: executors are snapshotted every 60s (PERFORMANCE_EXECUTOR_SNAPSHOT_INTERVAL) against a 5m default, so at steady state roughly five rows per executor compete for each retained slot — across the whole fleet. The more executors are live, the smaller the fraction returned.

Who is affected

Condor's dashboard is not affected: PerfBrowser.tsx:1341 always sends a single executor_id, gated on enabled: !!execScopeId, so each window holds only that executor's rows. Re-verified after 34 commits that rewrote 240 lines of that file and added an Executors view — still exactly one call site in the whole frontend, still narrowed. But the protection is incidental: nothing in the code or its tests says the unnarrowed query is unsafe, so a future caller has no warning.

Condor's HTTP API is affected. GET /api/v1/servers/{name}/performance/history?subject=executor accepts the request with no executor_id and forwards it verbatim, so any caller of Condor's own API — an agent, an MCP tool, a script — gets the partial fleet, as does anything calling this API directly.

Worth noting the route's docstring warns that interval is "a floor, not a guarantee". That is a claim about resolution; a reader has no reason to expect scopes to disappear.

The same shape exists in the controller sampler and is already documented as upstream behaviour in Condor's condor/fetchers/run_history.py — "the same 12-controller fleet over the same window answers with 12 of 12 controllers at 5m and 11 of 12 at 1h."


2. A POSITION_HOLD executor's PnL is realized in one surface and unrealized in the other

For one terminated LP executor, read at the same moment in the same view:

source realized unrealized net
API /performance/history row 0.00 2.00 2.00
Condor scope panel header +$2.00 +$0.00 +$2.00

Both readings are defensible. models/performance.py treats POSITION_HOLD as unsettled on purpose — the close hands the position on to position_holds, so counting it as realized would double-count it, the same exclusion ExecutorRepository.get_performance_report applies. Condor's panel reads the executor record, where a TERMINATED executor's PnL is realized by definition.

The reason this belongs on this PR: before FEAT-087 there was no API-side realized/unrealized split for an executor, so there was nothing for the panel to contradict. They now sit one above the other on the same screen — the header above, and the chart it feeds below.

Someone has to own which one is the fact. The API's reasoning looks the sounder of the two, which would make the fix a Condor-side change — but it needs deciding rather than leaving both on screen.

Caveat on this one: it was observed against seeded rows (is_terminal = true, close_type = 'POSITION_HOLD', status TERMINATED). A real held LP position does terminate that way, so I believe the fixture is faithful, but it is worth confirming against a real one before acting.

@cardosofede

Copy link
Copy Markdown
Contributor Author

@david-hummingbot thanks — both confirmed, fixed in 1c9ca97.

1. The sampler dropped whole executors

Fixed as diagnosed: _sample_by_interval now keeps one cursor per executor_id instead of one for the merged series. The interval now means what the parameter says — each executor's own series is thinned, no scope leaves the answer. Input order is preserved, so the series stays descending by timestamp and pagination is untouched.

Your repro, replayed against the fixed sampler:

interval rows executors pairs represented
1m 5 3 of 3 BTC-USDT, ETH-USDT, SOL-USDC
5m — the default 3 3 of 3 BTC-USDT, ETH-USDT, SOL-USDC
15m 3 3 of 3 BTC-USDT, ETH-USDT, SOL-USDC
1h 3 3 of 3 BTC-USDT, ETH-USDT, SOL-USDC

I took the controller sampler too, rather than leaving it as documented upstream behaviour. It is the same defect in the same shape, subject=controller now serves it, and the row shape does not change — so /bot-orchestration/controller-performance-history stays wire-compatible, it just stops hiding controllers. Keyed on (bot_name, controller_id); your 12-controller fleet is a test now, 12 of 12 at 1h.

Five tests added, since your point that nothing in the code or its tests said the unnarrowed query was unsafe was the part worth fixing permanently: a fleet survives 5m and 1h, each scope's own series still obeys the interval (grouping must not quietly disable the thinning), the merge order is still newest-first, and the controller equivalent.

One residual I documented rather than fixed: per-scope cursors do not survive a page boundary, because next_cursor is a timestamp. So an executor's first row on page two can sit closer than interval to its last row on page one. That over-samples a scope by at most one row per page; it never drops one. Carrying per-scope state would mean an opaque cursor, which did not seem worth the wire change for that.

Your note about the docstring landed too. "A floor, not a guarantee" was indeed a claim about resolution only, and a reader had no reason to read scope-completeness into it. It now says explicitly that sampling is per scope and that an unnarrowed query over a fleet returns the whole fleet at every interval, at a lower resolution.

2. POSITION_HOLD realized in one surface, unrealized in the other

Agreed the API side is the sounder of the two, so I've kept it and made the stance explicit instead of leaving it as reasoning only visible in the source. The rule is now in the realized_pnl_quote / unrealized_pnl_quote field descriptions — so it reaches a consumer through the OpenAPI schema — and the route docstring spells out the surprising combination directly: a held executor is is_terminal: true, status: TERMINATED, and its PnL is still in unrealized_pnl_quote, because the position carries on in position_holds and counting it realized double-counts it against /executors/performance.

That leaves the actual change on the Condor side: the panel derives realized from TERMINATED on the executor record, and there is nothing this API can do to force the two to agree — it can only say which one is the fact, which it now does. Happy to file that against Condor if you'd rather it be tracked there.

On your caveat: the fixture looks faithful to me. POSITION_HOLD is exactly how a held LP position terminates (services/executor_service.py closes with it on keep_position=True, and again on an involuntary hold with hold_reason set), and ExecutorRepository.get_performance_report excludes that same close_type from realized PnL for the same double-count reason. Still worth a look against a real one before the Condor change goes in.

Full suite green — 803 passed.

cardosofede and others added 22 commits September 8, 2026 00:05
…esults

The service keeps one BacktestingEngineBase so its data provider caches downloaded
candles across runs, but run_backtesting builds the run on that shared instance --
time window, controller, resolution, per-run accumulators -- and then suspends for
seconds on the historical candle download. Two runs interleaving there resumed
against each other's state and returned silently wrong numbers, with no exception:
POST /backtesting/run and POST /backtesting/tasks share the app-level singleton and
submit_task spawns unbounded background tasks, so this needed no unusual load.

Hold an asyncio.Lock across the whole run_backtesting await, following the guard
pattern in MarketDataService.fetch_connector_tickers. The candle cache is preserved,
unlike a fresh per-run engine. The controller-config classmethods stay outside the
lock, being stateless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_sync_orders_to_database` awaited `get_order_by_client_id` inside the loop over
`connector.in_flight_orders`, so every 60s sync cost one sequential round trip per
open order — plus a flush per order whose status moved — while holding a pooled
connection for the duration.

Add `OrderRepository.get_orders_by_client_ids`, a batched `.in_()` sibling of the
single lookup (chunked at 500 ids), fetch the connector's rows once, index them by
client order id and iterate in memory, flushing once only if a status actually
changed. Terminal orders are still popped from `in_flight_orders` after the session
closes, and orders with no DB row are still skipped.

Also whitespace-only fixes in `order_repository.py` so the repo's flake8 hook
accepts the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_handle_log pruned its dedup cache by walking the entire _processed_messages
dict on every incoming log line, making the cost of ingesting bot logs
quadratic in the log rate on the same consumer coroutine that handles
heartbeats and controller performance reports.

Entries are inserted in non-decreasing timestamp order, so expiry only needs
to touch the oldest end: _processed_messages is now an OrderedDict that is
popped from the front while the oldest entry is expired, which is O(expired)
instead of O(cache size). A _max_processed_messages cap with FIFO eviction
also keeps the cache bounded, mirroring the maxlen deques already used for
_bot_logs / _bot_error_logs.

Also drops two dead local assignments and trailing whitespace in the same
file so the pre-commit flake8 hook passes on it; no logic changed there.

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

SEC-058. create_hummingbot_instance re-reads `controllers_config` from the
deployed script config on disk and joined every entry into a source and a
destination path with no validation. That YAML is attacker-controlled at the
trust boundary (POST /scripts/configs/{name} writes an arbitrary dict), so an
entry like `../../../.env` copied the API's own env file into bots/instances/.
The SEC-044 validators only ever ran on the request body, never on this list.

Each entry is now checked as a safe single path component with the same
validator the deploy request bodies use, and both resolved paths are run
through _ensure_contained against bots/conf/controllers and the instance's
conf/controllers. Failing entries are skipped with a warning instead of
aborting the deployment, matching the existing not-found branch.

_validate_safe_config_name is renamed to validate_safe_config_name so the
service layer can reuse it without reaching into a private name.

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

The pre-flight `if not await gateway_client.ping(): raise 503` was copy-pasted into 39
route handlers across four routers (32 inline, plus 7 going through a private
`_require_gateway` helper in gateway_amm.py). A cross-cutting precondition expressed
39 times cannot be changed centrally — caching the ping or adding a Retry-After was 39
edits — and any new Gateway route silently shipped without it.

`deps.py` now owns it as `require_gateway_online`, attached per route with
`dependencies=[Depends(require_gateway_online)]`. Per route, not per router: the
container-control routes (/gateway/start, /stop, /restart, /logs, /status) must answer
precisely when Gateway is down, and the DB-backed reads (/clmm/pools, the position and
swap searches, the event feeds) keep serving stored data regardless. Those 22 routes
stay unguarded, and /clmm/positions/search keeps treating a failed ping as "skip the
refresh" rather than an error.

The `except HTTPException: raise` clauses are deliberately left in place: most handlers
still raise their own 400/502 inside the try, and for the rest the clause is cheap
insurance against the next in-try raise being rewritten into a 500.

One behavioural note: the guard now runs before request-body validation, so a malformed
body sent while Gateway is down answers 503 instead of 422.

Also wraps two over-length signatures in routers/accounts.py (delete_credential,
add_credential) that predate this change — format only, no logic — because the
pre-commit flake8 hook lints the whole staged file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gateway names a write's transaction id three ways — `signature` on Solana, `txHash`
on EVM, `hash` on older shapes — and that extraction was copy-pasted into seven
handlers across the CLMM, AMM and swap routers. Six copies read all three keys; the
CLMM open handler's copy read `signature` alone, so a uniswap or pancakeswap open
that Gateway had confirmed came back as a 500 saying no transaction signature was
returned, for a position that had just been opened on-chain.

The AMM event recorder held a third variant: it read `signature` and `txHash` but
not `hash`, and its log line read `signature` alone, so every EVM AMM write was
logged as "Recorded AMM ADD_LIQUIDITY: None".

get_transaction_hash_from_response now lives in routers/gateway_extras.py beside
get_transaction_status_from_response and transaction_id_from_error — the success
and failure paths for the same question — and all seven call sites go through it.
The AMM recorder keeps its "" fallback, since it records what it has rather than
failing a write whose liquidity has already moved.

The other two halves of ARCH-054 — the duplicated status parser and the diverging
chain->gas-token map, whose ternary wrote gas_token NULL on every chain outside
solana/ethereum — were already consolidated into gateway_extras.py and
gateway_client.py by earlier work, but nothing pinned them. test_gateway_response_
parsing.py now does: AST guards that each parser has exactly one definition, that
only gateway_client.py holds a chain-keyed gas-token dict, and that every gas_token
write resolves through get_native_gas_token; plus a functional test driving
/clmm/add, /clmm/remove and /clmm/collect-fees on base-mainnet and asserting all
three persist the same "ETH". Reintroducing either the ternary or the signature-only
open fails four of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An executor that closed in milliseconds could sit at status=RUNNING forever.
create_executor starts the executor synchronously and only then awaits
_persist_executor_created, so the 1s control loop can see is_closed and write the
completion first. That write went through update_executor, which is
select-then-update and silently no-ops when the row is missing -- while the caller
logged success -- and the creation INSERT then landed afterwards with RUNNING.
The same no-op swallowed the final state whenever the creation INSERT failed at
all, since _persist_executor_created catches every exception; that executor never
appeared in search or the performance report, and cleanup_orphaned_executors could
not repair a row that did not exist.

Add ExecutorRepository.upsert_executor_completion, the insert-or-update shape
upsert_position_hold already uses: it updates the row, or inserts it from the
metadata the caller carries, in a SAVEPOINT that tolerates a creation INSERT
winning the race in another session. _persist_executor_completed calls it and logs
when a row had to be repaired; _persist_executor_created now treats a duplicate
INSERT as the benign end of the race rather than an error, so it can no longer
resurrect a closed executor to RUNNING.

Deliberately not reordering the two writes: _instantiate_and_register raises
HTTPException(400) when instantiation fails, which would commit a RUNNING row for
an executor that never started.

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

get_candles_feed tested `feed_key not in self._candle_feeds`, then awaited
_validate_pair (an exchange-data load plus a REST candle probe, hundreds of
milliseconds) before starting the feed and registering it. Two concurrent
first-touch callers for the same key both passed the guard, both started a
feed, and the loser of the assignment kept a live exchange subscription with
no reference in _candle_feeds -- so stop_service, stop_candle_feed,
manually_cleanup_feed and _cleanup_unused_feeds, which all key off that dict,
could never stop it. The /candles and /historical-candles routes and the
websocket subscribe path make that collision routine.

Guard the create-and-register section with a per-feed_key asyncio.Lock and
re-check membership inside the lock, using the same setdefault idiom already
used for _ticker_locks in this file. The lock entry is pruned alongside the
feed in all four teardown paths, skipping a lock that is still held so the
next caller is not handed a fresh one mid-creation.

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

`get_performance_report` ran a second, unlimited `SELECT net_pnl_quote` over every
completed executor purely so `ExecutorService` could take a mean and a variance in
Python. The list had exactly one consumer -- the Sharpe ratio -- so the report scanned
and transferred the whole executors table, and the `/ws/executors` performance push
loop polls it every `update_interval` seconds per subscriber. At 100k completed
executors that was a 100k-row fetch twice a second per connected client.

The count, the sum and the sum of squares give the sample standard deviation directly,
so the second moment joins the aggregate query that already computes sum/avg/count/win
rate over the same filter. The repository now returns `pnl_std` and `completed_count`
instead of `pnl_values`, and the service divides one by the other. Row count is O(1) in
the table size.

The moments are subtracted in Decimal rather than float: a large mean with a small
spread otherwise loses its whole variance to cancellation. That is also what Postgres
does inside `stddev_samp` for a numeric column, which the item proposed -- deriving it
here instead keeps the SQL runnable under the in-memory SQLite the repository tests use,
where `stddev_samp` does not exist.

`sharpe_ratio` is unchanged: still null below two completed non-POSITION_HOLD
executors and when every PnL is identical, still the same number otherwise.

Closes PERF-062.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stop-and-archive endpoint built three aliases of the same string —
actual_bot_name, container_name and bot_name_for_orchestrator — from the
path parameter with no intervening mutation, so the "bot found" check
OR'd a membership test with itself and the orchestrator applied the three
provably identical names inconsistently across its 8 steps. The prefix
handling this was left over from is gone: docker_service creates the
container as `name=instance_name` under bots/instances/{instance_name}.

Collapse them to a single bot_name in both the router and
BotsOrchestrator.stop_and_archive_bot, matching how the neighbouring
/stop-bot endpoint already passes the name through unmodified.

Response-contract change: the `details` object of both responses now
carries one `bot_name` key in place of `input_name`, `actual_bot_name`
and `container_name`, which always held the same value.

Adds tests pinning that the name used for every destructive step (stop
container, archive directory, remove container, mark archived) is the
path parameter verbatim, plus the not-found branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_authenticate_websocket` fell back to `?token=base64(user:pass)` and to bare
`?username=`/`?password=` when no Authorization header was present. uvicorn logs the
full path *with* its query string for every websocket handshake, so those channels
wrote the single global admin credential pair into access logs, logfire traces,
reverse-proxy logs and browser history — one INFO line was a full disclosure.

Credentials are now read from headers only. Browsers cannot set headers on
`new WebSocket(...)`, so before removing the query-param branches this adds the
channel they can use: `Sec-WebSocket-Protocol: hummingbot-auth, base64url(user:pass)`,
with the selected subprotocol echoed back in `accept()` (a browser fails the
connection if the server does not echo it). base64url is the only base64 variant whose
alphabet is a valid subprotocol token; standard base64 is still accepted on both
channels.

Both endpoints also accepted the handshake *before* authenticating, so an
unauthenticated peer always reached an open socket and was only then closed with 4001.
Authentication now runs first and a failure refuses the handshake itself, with an HTTP
401 + `WWW-Authenticate: Basic` denial response where the ASGI server supports the
extension and a 1008 close where it does not.

Note: this is a breaking change for any client passing credentials in the URL, as the
item specifies — the subprotocol channel is its browser-side replacement.

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

The /ws/executors clamp used three module literals (0.5 / 60.0 / 2.0) in
services/executor_ws_manager.py, so MARKET_DATA_WS_* only ever reached
/ws/market-data and the operator knob silently did nothing for half the
WebSocket surface.

Add executor-specific fields on MarketDataSettings and read them inside
_clamp_interval, the way WebSocketManager._clamp_interval already does.
The executor floor stays stricter than the market-data one (0.5 vs 0.25):
executor push loops poll the database, not in-memory candles and order
books. Defaults are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The executor WebSocket manager carried seven structurally identical
`_*_push_loop` coroutines: fetch, hash, send-if-changed, sleep, swallow
CancelledError. Only the fetch expression, the frame `type` and at most one
derived key (`total_count`, `bot_count`) differed, so every change to the
polling contract had to be made in seven places.

Collapse them into a single `_push_loop` driven by a
`sub_type -> PushSpec(fetch, msg_type, extra)` table, with the per-type payload
shaping moved into `_fetch_*` wrappers that also normalise the mixed sync/async
service calls. `_logs_push_loop` stays separate: it keys on `last_log_count`,
not on a payload hash.

With one place left to edit, add what the sibling `services/websocket_manager.py`
already does and these loops did not: a disconnected client now ends the loop
instead of logging a push error every interval. The guard wraps only the send —
a `RuntimeError` out of a fetch is a service fault, not a dropped client, and
stays a logged-and-retried error as before.

Frame contents, key order and change detection are unchanged. The only
behavioural difference on the wire is that a dropped client stops the loop, and
the `executor_summary` error log now says `executor_summary` rather than
`summary`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
run_backtesting is awaitable, but past the candle download it is an uninterrupted
CPU loop over every candle: awaited inline it executed on the API's only event
loop thread. A single slow run pinned that thread at 100% for hours -- every
endpoint, /docs included, stopped answering, the container still looked healthy
to every liveness check, and only a restart cleared it. DELETE
/backtesting/tasks/{id} reported CANCELLED and changed nothing, because asyncio
delivers a cancellation at an await and the simulation offered none.

Each run now executes in a spawned worker process, supervised from the loop by a
short poll. The loop stays free, a cancellation lands within a poll interval and
signals the worker dead -- the only thing that stops a wedged CPU loop -- and a
run that overruns its wall-clock budget is terminated and reported as failed
instead of burning a core forever. A semaphore caps how many runs are in flight
so a burst queues rather than claiming a core each. The outcome crosses back
through a file rather than a pipe: a result is megabytes and a pipe holds tens of
kilobytes, so a child blocking in send() against a parent waiting for it to exit
would deadlock.

This also subsumes CORR-060. That lock existed because one shared
BacktestingEngineBase is mutated in place by each run and then suspends on the
candle download, so overlapping runs returned silently wrong numbers. A worker
process owns its engine outright, which makes that state unshareable by
construction rather than by discipline, and the lock is gone. Its regression
tests are kept and rewritten against the process mechanism, plus the cap. The
trade-off CORR-060 documented stands: a per-run engine loses the per-instance
candles cache, so candles are downloaded once per backtest -- a shared cache is
the follow-up.

Verified against a real 3-day BTC-USDT backtest: results, processed_data,
position_holds and pnl_timeseries are byte-identical to the inline path, and only
the randomly generated executor id differs, as it does between any two runs.

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

The two gateway routers owned their own database sessions: 15 `get_session_context`
blocks across 1409 + 525 lines, each constructing a repository inline, hand-building
the row dict, and carrying its own copy of the "log the write failure but don't fail
the trade" policy with a different message every time. The persistence rules for a CLMM
position lived only inside HTTP handlers, which is why the transaction poller's
auto-discovery path had to re-derive them with a divergent key set, and why none of it
could be tested without FastAPI.

GatewayCLMMService and GatewaySwapService now own that, constructed once in main.py next
to TradingHistoryService and injected through deps. Both sit on a RepositoryService base
that splits the scaffold in two on purpose: `_in_repo` propagates, so a read that must
answer 404 or 500 still can, and `_in_repo_best_effort` is the one place the swallow
policy is written. Handlers reduce to: call Gateway, hand the service what happened,
answer the caller.

Nothing about the rows changes. test/test_gateway_router_persistence.py drives the real
routes and pins every column of the position, event and swap rows each handler writes,
both bookkeeping paths (CONFIRMED books, SUBMITTED and FAILED do not), the two read
envelopes the handlers used to assemble by hand, and the 404-vs-500 distinction that a
swallowing helper would have erased.

`_refresh_position_data` moved with the sessions it needed and now takes the gateway
client rather than the whole AccountsService. ARCH-053's `require_gateway_online` guard
is untouched on all 39 routes; ARCH-054's parsers stay in routers/gateway_extras.py,
with `_record_failed_write` left in the router as a shim over the single
`transaction_id_from_error` — services/ still imports nothing from routers/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An executor's row was written exactly twice: zeros at creation, the real figures
at completion. So a RUNNING executor's row said its PnL was zero for its entire
life -- the live numbers existed only in ExecutorService memory, which is why
get_executors and get_performance_report merge _active_executors over the
database on every read.

Two things followed. There was no series to chart, and worse, an API restart
destroyed the accounting of every executor that was live: cleanup_orphaned_executors
flipped each RUNNING row to TERMINATED/SYSTEM_CLEANUP and touched none of the PnL
columns, so the creation-time zeros became the permanent record and
/executors/performance summed them forever.

executor_performance_snapshots is written by ExecutorService from _control_loop on
its own interval (60s by default -- a position executor can live three minutes and
would get one point at the controllers' 5-minute grain), plus one is_terminal row at
completion in the same transaction as the record update. The terminal row is what
makes a closed executor's series a single-table query: no join to executors, no "and
then append the final value" rule for every future reader, and no exposure to the two
paths that leave that record at zero. It sits behind a SAVEPOINT because the coupling
has a direction: the record update is the accounting, the snapshot is a point on a
chart, so a failing snapshot rolls back only itself.

The reap then adopts the last snapshot, which is what turns this from a chart into
the accounting no longer being wrong. An executor with no snapshot keeps today's
behaviour, and the row stays SYSTEM_CLEANUP so an approximated close is still
visible as one.

Typed columns rather than the controller table's JSON blob: ExecutorInfo is named
field by field all over this repo, unlike the core-versioned PerformanceReport, and
typing it keeps fill_events and grid levels out of a per-minute row. There is no
second volume column -- filled_amount_quote is the volume traded on every executor
type, including LP.

PERFORMANCE_RETENTION_DAYS defaults to 0, which deletes nothing: an upgrade must not
start deleting an operator's history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaWh5TSqusPXeAefzzJjfC
GET /performance/history?subject=controller|executor returns both populations under
one normalized row shape, paged and cursored identically, so a consumer writes
seriesFor(scope) once instead of maintaining two clients. Two URLs returning the same
shape would only move the branch from a parameter to a path and buy nothing; the cost
is the ~6 lines of explicit 400 for a filter aimed at the wrong population, which
FastAPI cannot express and which would otherwise be accepted and silently ignored.

The two subjects are stored differently on purpose -- the controller's payload is an
opaque, core-versioned PerformanceReport that has to be blobbed, the executor's is
typed columns -- because what has to be symmetric is the response, not the storage.
The normalization is additive rather than lossy: everything the report carries with no
executor counterpart (open_order_volume, inventory_imbalance, positions_summary,
close_type_counts) stays reachable in the performance passthrough, so a client
migrating off /bot-orchestration/controller-performance-history loses nothing.

cum_fees_quote is null for controllers rather than zero, because PerformanceReport
genuinely has no fees field and unknown is not the same as measured-and-empty.

subject=controller goes through BotsOrchestrator.get_controller_performance_history
unchanged -- the same call the existing route makes -- so the two share one query path
by construction and cannot drift. The two existing controller routes and
ControllerPerformanceRepository are untouched: they are consumed externally, and this
is new surface only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaWh5TSqusPXeAefzzJjfC
The design doc moves to features/done/ with its commits annotated, its
implementation-plan steps and acceptance criteria marked, and the three deviations
from the design recorded: the terminal row sits behind a SAVEPOINT so a failing
snapshot cannot roll back the completion, a snapshot is skipped rather than zeroed
when executor_info cannot be read, and the retention sweep runs on its own hourly
guard inside the snapshot tick.

Also notes what verification found: the suite needs asyncio_mode=auto, which is
configured nowhere in-tree, and the env's pytest/pytest-asyncio pair is incompatible,
so 82 async tests fail before any change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaWh5TSqusPXeAefzzJjfC
/performance/history gave a consumer one series call for both populations, but
it still had to fall back to /bot-orchestration/controller-performance-latest
for the live tiles -- and had nothing at all for executors. This adds
/performance/latest as the second half of that pair, so latestFor(scope) is
written once and a consumer can leave the bot-orchestration surface entirely.

- ExecutorPerformanceRepository.get_latest: the last snapshot of every matching
  executor. The filters narrow the grouped subquery instead of the join, so the
  whole table is not aggregated and then discarded. Newest-first with a limit,
  because every executor that ever ran leaves a terminal row behind and the
  population grows without bound -- the live ones belong at the top.
- A closed executor's latest row IS its terminal row, so "its final value" and
  "its current value" are one query, with is_terminal and close_type on it.
- The controller subject goes through get_latest_controller_performance, the
  same method the old route calls, so the two cannot drift. controller_id and
  the limit are applied in the route: the result is one row per (bot,
  controller) and small by construction.
- Both routes share one _reject_foreign_filters helper, so they cannot disagree
  about which filter belongs to which population. A malformed controller
  timestamp sorts last rather than 500ing a whole dashboard.

Reads the stored series, not memory: this row and the last row of /history are
the same row, by design. Live figures stay on /executors/. The old routes are
untouched and wire-compatible; this is new surface only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aemK7XRzg7bMuzDTd2fQb
Two suites that were written alongside the fixes they cover but never landed
with them.

- test_bot_runs_payload: final_status is ~99% of a bot run record's bytes, so
  the list endpoint must omit it while the detail endpoint keeps it.
- test_ticker_sources: ascend_ex and okx_perpetual both report BASE volume in a
  field that used to be stored as quote volume, which made cross-exchange
  liquidity comparison meaningless. These are regression assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aemK7XRzg7bMuzDTd2fQb
FEAT-001 replaced only half of the surface it set out to replace.
/bot-orchestration/ carries two controller routes and /performance/history
answered only -history, so a consumer could migrate its charts but still had to
call the old route for current values -- not a migration a client can finish,
which was the objective. The addendum records /performance/latest closing that,
and the design calls behind it: newest-first with a limit because every executor
that ever ran leaves a terminal row and the population grows without bound;
filters on the grouped subquery rather than the join; the controller branch
reusing get_latest_controller_performance so old and new cannot drift; and no
pagination block, because one row per scope is not a series.

Also records what a full migration still needs, since the doc is where the next
reader will look for it: the old routes stay wire-compatible and consumers move
when they choose, subject=executor covers only in-process executors and never a
Docker bot's, and PERFORMANCE_RETENTION_DAYS still defaults to keep-forever.

Verified against the deployed API: /latest's row and the newest row of /history
come back byte-identical, which is the property the design turns on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQvZV28iuCWGAyCfYp5kT5
Both directories are working notes for the /improvements and /design-feature
burndown runs, not repo content. improvements/ was already ignored but features/
was tracked; untrack it and ignore both consistently. The files stay on disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012FvsHXDNi7i7gJ7ogLdAGC
cardosofede and others added 14 commits September 8, 2026 00:05
stop_and_archive_bot returned silently when the bot process refused to
stop and when the container was still alive after every retry, leaving
the run row stopped-but-not-archived with no error_message while the
caller had already been told the background task started fine.

Both paths now close the run out through a new mark_bot_run_errored
helper, which also absorbs the two error blocks that already did this
inline (container removal failure and the outer exception handler).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
`get_active_orders` ended with a hardcoded `.limit(1000)`, so an account with a
larger active book silently started up missing its oldest orders: they were
invisible to order sync and reconciliation while still live on the exchange.

The bound is now an explicit `limit` parameter defaulting to the previous 1000
(other callers keep their behaviour), `_load_existing_orders` passes
`limit=None` for the complete book, and a query that reaches its cap logs a
warning naming the count and the limit so truncation is never silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
get_performance_report wrapped its whole database block in a bare
except Exception, logged, and returned the zeroed report it had already
built. An unreachable database was therefore indistinguishable from
"no executors yet": the route answered 200 with zeroes and the
/ws/executors performance channel pushed a confident row of them to
dashboards, with no error state and nothing marking them stale.

The failure now propagates. The route already turns it into a 500, and
the shared push loop sends one error frame naming the channel and the
subscription before it goes back to retrying quietly -- and clears the
payload hash so the recovery frame is sent even when the report comes
back byte-identical. Zeroes are left to mean an empty dataset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
Closing a CLMM position awaited asyncio.sleep(2) — the wait for the close
transaction to propagate — inside the open database session, so a pooled
connection sat idle for two seconds per close while the database had nothing
to do. A fleet closing several positions at once could drain the pool that
way.

Split the unit of work around the wait: one session books the CLOSE event and
the final fees, then it is released; the propagation wait and the Gateway
re-read happen with no connection held; a second short session marks the row
CLOSED. Same rows, same values, same order, and a write failure is still
swallowed rather than reported as a failed close.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
reconcile_active_orders called update_order_status once per tracked order,
so each order cost its own SELECT plus a flush inside a savepoint. Read the
whole tracked book with a single get_orders_by_client_ids (chunked at 500),
index it by client order id, mutate in memory and flush once, and only when
a status or note actually moved -- the same shape PERF-049 gave the sync
path. Tracking is now updated after the transaction commits, so a failed
write leaves every order tracked for the next startup instead of silently
dropping it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
get_executor_stats issued seven statements for a payload that is entirely
aggregates: four unfiltered scalars (two COUNTs, two SUMs, none of them
narrowing anything) and three separate GROUP BY passes over the same table.

The four scalars share a single filter -- none -- so they fold into one
aggregate row, with the RUNNING count as a SUM(CASE) beside them. The three
breakdowns become one statement grouped by (executor_type, status,
connector_name) and pivoted back into the three dicts in Python; the grouped
row count is bounded by the distinct combinations, not the table size.

Same aggregate shape get_performance_report was reduced to next door. The
returned dict is unchanged, which the new test pins against the seven-query
version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
require_gateway_online runs ahead of 39 routes and each run was a full HTTP
round-trip to Gateway. Cache the ping verdict in GatewayClient for a 2s TTL, so
a burst of guarded requests costs one round-trip, and clear it whenever a
Gateway call fails to connect (or its mTLS certs are missing) so an outage
mid-TTL is never masked by a stale "available". The 503 now carries Retry-After
set to that TTL; the detail string and its single source are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
POST /docker/remove-container/{name} refused any container whose name did not
start with "hummingbot-". Nothing has named bot containers that way since the
prefix was dropped: create_hummingbot_instance passes the instance name to
Docker verbatim. The guard therefore rejected exactly the containers this API
creates, while accepting the infrastructure containers that do carry the
prefix (hummingbot-postgres, hummingbot-broker) and then trying to archive a
bots/instances directory that never existed for them.

Replace it with the invariant the endpoint actually needs: a container is
removable here only if it owns the bots/instances/<name> directory this
endpoint archives. That accepts every API-created bot whatever its name,
refuses unrelated host containers, and - via the existing containment helper,
now exposed as DockerService.resolve_instance_dir - refuses a name that
escapes bots/instances before anything is removed or archived.

Also satisfies the repo's pre-commit gate on the touched file: isort ordered
its imports and the trailing whitespace / long signature flake8 flagged are
cleaned up. Whitespace only, no behaviour change.

Closes READ-115.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
A subscribe message carrying {"update_interval": "fast"} reached the
clamp's max/min comparison and raised TypeError out of handle_subscribe,
surfacing as an unhandled exception rather than the _send_error response
every other malformed-input path returns.

_clamp_interval now raises ValueError for anything that is not a real
number (bool excluded, being an int subclass that would silently clamp
to the floor), and handle_subscribe turns that into the usual error
frame naming the offending field without creating the subscription.
Valid numbers, a missing interval and the configurable bounds are
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
ARCH-109. The AMM and archived-bots routers were the last two that still opened
their own `get_session_context` blocks, so the convention was inconsistent in
precisely the two files a newcomer is most likely to copy from.

`GatewayAMMService` sits on the `RepositoryService` base that ARCH-052 introduced
for exactly this, alongside `GatewayCLMMService` / `GatewaySwapService`: reads
propagate (`_in_repo`), and the "record it, but never fail a write that already
happened on-chain" policy is expressed once (`_in_repo_best_effort`) instead of
four times. The event row shape, the DAMM v2 position bookkeeping and both
pagination envelopes move with it. The router keeps only the parsing of Gateway's
response — the amount keys differ per event type, and `transaction_id_from_error`
stays in `routers/gateway_extras.py` so `services/` never imports from `routers/`.

The single block in `archived_bots.py` goes to `BotsOrchestrator`, which already
owns bot-run persistence since ARCH-035, rather than to a new service for one call.

Behaviour-preserving: `test/test_amm_router_persistence.py` pins every column both
AMM paths write, both envelopes (including that the clamped query still echoes the
limit as asked), and the swallow policies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
The generated .env is the only place an operator sees a knob, and it comes
from a hand-maintained heredoc in setup.sh that nothing derives from
config.py. Five of the nine settings groups had drifted out of it entirely:
PERFORMANCE_, BACKTESTING_, MARKET_DATA_, CORS_ and AWS_. The sharpest case
is PERFORMANCE_RETENTION_DAYS, whose default of 0 keeps every snapshot
forever -- an operator who does not know the variable exists cannot discover
that their database grows without bound, nor cap it.

Write each of the five groups into the heredoc commented out with its
config.py default and description, so a fresh .env documents the knob
without pinning a value config.py owns; the active assignment set is
unchanged, so a fresh .env produces byte-identical runtime settings. Point
the README's curated excerpt at config.py as the authoritative list.

Pin the template against config.py in test: every prefixed group must be
represented, every documented setting must exist, and every default shown
must be the one config.py applies -- so the next group added cannot drift
out of the template silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
The transaction poller built GatewayCLMMRepository / GatewaySwapRepository itself in
six places, so it owned a second write path into tables the /gateway/clmm and
/gateway/swap routes also write. The two paths had drifted: a position the discovery
sweep found carried lower_bin_id, upper_bin_id and the pending fees; one the open
route recorded carried position_rent and none of those. The same logical position had
two different row shapes in one table, and anything reading it back had to know which
writer it was looking at.

build_position_row is now the only thing that decides what a new gateway_clmm_positions
row contains, and its key set is the union of the two: a caller that cannot know a value
passes nothing and gets NULL (bins the open route never learns, rent no discovered
position locked through us), and zero only where zero is the fact. Discovery keeps
everything only the chain can tell it.

The poller reads its pending book and its open positions as plain dicts and writes
through the services, which also ends the session it used to hold open across a whole
cycle's worth of Gateway calls. Its own decisions are untouched: the availability gate,
the dropped grace window, the retry age and the consecutive-miss gate all still say when
a row may change, and are now pinned by tests since every call site moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
A run owns its engine in its own process, which is what makes it killable and
what makes two runs unable to corrupt each other -- and it cost the per-instance
candles_feeds cache, so an optimizer sweeping N configs over one market
downloaded that market's full history N times.

The cache goes back outside the process, holding the only thing safe to share:
the candle data. No engine, provider or controller is reachable from two runs,
and every reader unpickles its own copy, so nothing one run mutates can reach
another.

Three properties make it safe to serve. An entry is keyed by everything the
download is a function of -- market, interval, max_records and the run's window
-- so a hit covers exactly the range asked for and a wider window misses. An
entry is only served for a bounded time, because a window ending near now is
fetched with its last candle still forming. And the number of entries is capped
with the least recently used dropped, so sweeping across pairs and timeframes
cannot grow the store without limit. A frame upstream reused from its own in-run
dict is never stored, since it was fetched for a shorter lookback than the key
it would land under.

Workers that miss the same key together take the entry file as a lock and
re-check inside it, so raising BACKTESTING_MAX_CONCURRENT does not re-multiply
the download. A cache is an optimization, never a failure mode: every read and
write degrades to "download it again", and BACKTESTING_CANDLES_CACHE_ENTRIES=0
turns it off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UV33Qr3Z56GwkD4Wo8jjne
Both samplers thinned with one global timestamp cursor, which made `interval`
a rate limit on the merged series rather than on each scope's own. An
unnarrowed query interleaves every scope's rows on the same grain, so the
scope owning the newest row in each window survived and the rest were not
thinned but dropped -- absent from a 200 response, indistinguishable from
scopes that never reported. Three executors over a five-minute span answered
with one at the 5m default; a twelve-controller fleet answered with eleven
at 1h.

The cursor is now kept per executor_id, and per (bot_name, controller_id) on
the controller side. Input order is preserved, so both series stay descending
by timestamp and pagination is untouched. Per-scope cursors do not survive a
page boundary -- `next_cursor` is a timestamp -- so a scope can be
over-sampled by at most one row per page; it is never dropped.

The controller row shape is unchanged, so
/bot-orchestration/controller-performance-history stays wire-compatible; it
just stops hiding controllers.

Also states the two claims a reader had no way to check. `interval` was
documented as "a floor, not a guarantee", which is a claim about resolution
only -- it now says sampling is per scope and a fleet query returns the whole
fleet. And the POSITION_HOLD split, previously reasoned about only in the
source, is now in the field descriptions a consumer reads off the schema: a
held executor is terminal and TERMINATED with its PnL still unrealized,
because the position carries on in position_holds and counting it realized
would double-count it against /executors/performance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WexGCaGpo82gU8cEAg6opn
@cardosofede
cardosofede force-pushed the feat/burndown-improvements branch from 1c9ca97 to 8f3f056 Compare September 7, 2026 21:07
@david-hummingbot

Copy link
Copy Markdown
Contributor

Describe the bug

After an API crash/restart, an executor that was live at the time of the crash gets correctly reaped: ExecutorRecord.status becomes TERMINATED, close_type becomes SYSTEM_CLEANUP, and net_pnl_quote / cum_fees_quote / filled_amount_quote are adopted from its last performance snapshot instead of being zeroed (this is the fix in database/repositories/executor_repository.py::cleanup_orphaned_executors).

However, that reap path never inserts a corresponding terminal row into executor_performance_snapshots. The normal (non-crash) completion path does write a terminal row in the same transaction as the ExecutorRecord update (services/executor_service.py, around the _persist_executor_completed / SAVEPOINT block), but cleanup_orphaned_executors only mutates the executors table.

The effect: GET /performance/latest?subject=executor and GET /performance/history?subject=executor keep serving the executor's last live snapshot forever — status: RUNNING, is_terminal: false, close_type: null — even though GET /executors/{executor_id} correctly reports TERMINATED / SYSTEM_CLEANUP with the adopted PnL. The two surfaces permanently disagree about whether the executor is done.

This contradicts the documented design of the new performance routes (routers/performance.py): "A closed executor's latest row is its terminal row... 'its final value' and 'its current value' are the same query, with no join back to executors."

Reproduced twice on feat/burndown-improvements (commit 8f3f056) — identical result both times.

Steps to reproduce bug

  1. Create a live in-process executor directly:
    curl -s -u admin:admin -X POST "http://localhost:8000/executors/" \
      -H "Content-Type: application/json" \
      -d '{
        "account_name": "master_account",
        "controller_id": "repro",
        "executor_config": {
          "type": "position_executor",
          "connector_name": "binance_perpetual_testnet",
          "trading_pair": "BTC-USDT",
          "side": 1,
          "amount": "0.001",
          "leverage": 5,
          "triple_barrier_config": {
            "stop_loss": "0.05",
            "take_profit": "0.05",
            "time_limit": 900,
            "open_order_type": 1
          }
        }
      }'
  2. Wait at least one PERFORMANCE_EXECUTOR_SNAPSHOT_INTERVAL (default 60s) so a live snapshot is written. Confirm with:
    curl -s -u admin:admin "http://localhost:8000/performance/history?subject=executor&executor_id=<executor_id>"
  3. Simulate a crash: hard-kill the API worker process (not a graceful shutdown/SIGTERM — that runs the normal stop() path and won't reproduce this). Then let the process supervisor (or --reload) bring up a fresh worker, which runs cleanup_orphaned_executors() on startup.
  4. Compare the two surfaces:
    curl -s -u admin:admin "http://localhost:8000/executors/<executor_id>"
    curl -s -u admin:admin "http://localhost:8000/performance/latest?subject=executor"
  5. Query the snapshot table directly to confirm no terminal row was ever inserted:
    docker exec hummingbot-postgres psql -U hbot -d hummingbot_api -t -c \
      "SELECT executor_id, status, close_type, net_pnl_quote, cum_fees_quote, timestamp FROM executor_performance_snapshots WHERE executor_id = '<executor_id>' ORDER BY timestamp;"

Example output

GET /executors/<executor_id> (correct):

{
  "status": "TERMINATED",
  "close_type": "SYSTEM_CLEANUP",
  "net_pnl_quote": -0.0332054,
  "cum_fees_quote": 0.0314054,
  "filled_amount_quote": 78.5135
}

GET /performance/latest?subject=executor for the same executor_id (stale):

{
  "timestamp": "2026-09-08T15:04:02.974401+00:00",
  "status": "RUNNING",
  "is_terminal": false,
  "close_type": null,
  "unrealized_pnl_quote": -0.0332054,
  "cum_fees_quote": 0.0314054
}

Raw executor_performance_snapshots rows for the same executor — only the pre-crash live snapshots, no terminal row:

 executor_id  | status  | close_type | net_pnl_quote | cum_fees_quote |          timestamp
--------------+---------+------------+----------------+----------------+-------------------------------
 <executor_id>| RUNNING |            | -0.0332054     | 0.0314054      | 2026-09-08 15:03:02.951977+00
 <executor_id>| RUNNING |            | -0.0332054     | 0.0314054      | 2026-09-08 15:04:02.974401+00

Note there is no third row with status = TERMINATED / close_type = SYSTEM_CLEANUP, so /performance/latest has no terminal row to serve and keeps returning the last RUNNING one indefinitely.

@david-hummingbot

Copy link
Copy Markdown
Contributor

Describe the bug

BotRunRepository.update_bot_run_stopped (database/repositories/bot_run_repository.py) sets bot_run.stopped_at = datetime.utcnow() — a timezone-naive datetime — on a column declared TIMESTAMP(timezone=True). When this naive value is written, it is stored as if it were already in the database session's local timezone rather than UTC, so the persisted stopped_at ends up offset from the real UTC stop time by the server's local UTC offset (8 hours in this environment).

By contrast, BotRunRepository.update_bot_run_archived (same file) sets stopped_at = datetime.now(timezone.utc) — a timezone-aware UTC value — which stores and reads back correctly.

Because stop-and-archive-bot's workflow calls update_bot_run_stopped first and then update_bot_run_archived at the end, the second call overwrites the wrong value with a correct one, masking the bug for that path. The bug is permanent, however, for any bot that is stopped via the plain POST /bot-orchestration/stop-bot route and never archived (or not archived immediately) — its stopped_at stays wrong indefinitely.

deployed_at on the same row is set correctly (from a different code path) and is not affected — only stopped_at set via update_bot_run_stopped is wrong.

This matters beyond the raw field: condor (the companion Telegram/web client) reads stopped_at for run-duration display and for attributing performance-history windows to a bot run (frontend/src/lib/run-attribution.ts, frontend/src/lib/perf-tree.ts), so a wrong stopped_at on a stopped-not-archived bot skews those calculations by the server's UTC offset.

Reproduced twice on feat/burndown-improvements (commit 8f3f056) — identical 8-hour offset both times.

Steps to reproduce bug

  1. Deploy any V2 controller bot, e.g.:
    curl -s -u admin:admin -X POST "http://localhost:8000/bot-orchestration/deploy-v2-controllers" \
      -H "Content-Type: application/json" \
      -d '{
        "instance_name": "tz_repro",
        "credentials_profile": "master_account",
        "controllers_config": ["<any saved controller config>"],
        "image": "hummingbot/hummingbot:latest",
        "headless": true
      }'
  2. Wait for it to report running via GET /bot-orchestration/status.
  3. Note the current wall-clock UTC time, then stop it with the plain (non-archiving) stop route:
    date -u
    curl -s -u admin:admin -X POST "http://localhost:8000/bot-orchestration/stop-bot" \
      -H "Content-Type: application/json" \
      -d '{"bot_name": "<unique_instance_name>"}'
  4. Immediately compare the recorded stopped_at against the wall-clock time from step 3:
    curl -s -u admin:admin "http://localhost:8000/bot-orchestration/bot-runs" | python3 -m json.tool

Example output

Actual stop time (step 3): Tue Sep 8 15:32:29 UTC 2026

Bot-run record returned in step 4:

{
  "bot_name": "burndown_retest1-20260908-233145",
  "deployed_at": "2026-09-08T15:31:46.221226+00:00",
  "stopped_at": "2026-09-08T07:32:29.817197+00:00",
  "deployment_status": "DEPLOYED",
  "run_status": "STOPPED"
}

stopped_at (07:32:29 UTC) is exactly 8 hours behind the real stop time (15:32:29 UTC), while deployed_at (15:31:46 UTC) is correct. This was reproduced identically in two separate runs (06:56:56 vs actual 14:56:58, and 06:58:49 vs actual 14:58:49, and again 07:32:29 vs actual 15:32:29) — always exactly an 8-hour offset, matching this server's local UTC offset.

For comparison, when the same bot is stopped via stop-and-archive-bot instead, stopped_at ends up correct (14:56:00+00:00 for a bot deployed at 14:55:11+00:00) because the later update_bot_run_archived call overwrites the wrong value.

@david-hummingbot

Copy link
Copy Markdown
Contributor

Describe the bug

GET /archived-bots/{db_path}/performance and GET /archived-bots/{db_path}/summary both return 500 Internal Server Error for an archived bot whose TradeFill table is empty (i.e., every order it placed failed before ever filling — no trades at all).

Both routes go through utils/hummingbot_database_reader.py. get_trade_fills() reads the TradeFill table into a pandas DataFrame via pd.read_sql_query(...) and then calls:

trade_fills["cum_fees_in_quote"] = trade_fills.groupby(groupers)["trade_fee_in_quote"].cumsum()

When the table is empty, the resulting DataFrame's numeric columns come back as object dtype instead of a numeric dtype (pandas can't infer a numeric dtype from zero rows), and .cumsum() raises TypeError: cumsum is not supported for object dtype for an object-dtype column, which the route surfaces as a generic 500.

This is a plausible real-world case, not just a synthetic one: a bot with a misconfigured order size (e.g., below the exchange's minimum notional) will have every order rejected and end up with exactly this all-failed, zero-fill state once archived.

Steps to reproduce bug

  1. Deploy a controller whose orders will all be rejected by the exchange, e.g. a pmm_simple config with the per-level order notional below the trading pair's minimum notional (for BTC-USDT on binance_perpetual_testnet, minimum notional is 50):
    curl -s -u admin:admin -X POST "http://localhost:8000/controllers/configs/repro_zero_fill" \
      -H "Content-Type: application/json" \
      -d '{
        "id": "repro_zero_fill",
        "controller_name": "pmm_simple",
        "controller_type": "market_making",
        "connector_name": "binance_perpetual_testnet",
        "trading_pair": "BTC-USDT",
        "total_amount_quote": 100,
        "buy_spreads": [0.001, 0.002],
        "sell_spreads": [0.001, 0.002],
        "buy_amounts_pct": [0.5, 0.5],
        "sell_amounts_pct": [0.5, 0.5],
        "leverage": 5
      }'
    curl -s -u admin:admin -X POST "http://localhost:8000/bot-orchestration/deploy-v2-controllers" \
      -H "Content-Type: application/json" \
      -d '{
        "instance_name": "zero_fill_repro",
        "credentials_profile": "master_account",
        "controllers_config": ["repro_zero_fill"],
        "image": "hummingbot/hummingbot:latest",
        "headless": true
      }'
    (Each order's notional works out to ~$23.5, below the $50 minimum, so every order is rejected with Order notional 23.5... is less than the minimum notional.)
  2. Let it run briefly, then stop and archive it:
    curl -s -u admin:admin -X POST "http://localhost:8000/bot-orchestration/stop-and-archive-bot/zero_fill_repro-<timestamp>"
  3. Once archived, call the archived-bot performance/summary routes:
    DB="bots/archived/zero_fill_repro-<timestamp>/data/zero_fill_repro-<timestamp>.sqlite"
    ENC=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$DB', safe=''))")
    curl -s -w "\nHTTP:%{http_code}\n" -u admin:admin "http://localhost:8000/archived-bots/$ENC/performance"
    curl -s -w "\nHTTP:%{http_code}\n" -u admin:admin "http://localhost:8000/archived-bots/$ENC/summary"

Example output

$ curl -s -w "\nHTTP:%{http_code}\n" -u admin:admin "http://localhost:8000/archived-bots/<encoded_db_path>/performance"
{"detail":"Error calculating performance: cumsum is not supported for object dtype"}
HTTP:500

$ curl -s -w "\nHTTP:%{http_code}\n" -u admin:admin "http://localhost:8000/archived-bots/<encoded_db_path>/summary"
{"detail":"Error analyzing database: cumsum is not supported for object dtype"}
HTTP:500

Confirmed the underlying TradeFill table is empty for this archived bot (every executor's close_type was 8 / FAILED, and GET /archived-bots/<db>/orders returns "orders": []), while GET /archived-bots/<db>/executors correctly returns the failed executor rows. So the executors/orders surfaces handle the zero-fill case fine; only /performance and /summary crash on it.

Reproduced twice on feat/burndown-improvements (commit 8f3f056) — identical error both times.

cardosofede and others added 3 commits September 8, 2026 19:23
An executor that was live when the API crashed was reaped correctly on restart --
TERMINATED, SYSTEM_CLEANUP, and its metrics adopted from the last snapshot -- but the
reap only ever touched the `executors` table. It was the one path to TERMINATED with no
terminal row behind it, so the invariant the /performance routes are built on (a closed
executor's latest row IS its terminal row, no join back to `executors`) held for every
executor except the ones a crash caught.

/performance/latest and /performance/history kept serving those executors' last RUNNING
snapshot forever -- is_terminal false, close_type null -- while /executors/{id} reported
TERMINATED/SYSTEM_CLEANUP with the adopted PnL. Two surfaces permanently disagreeing
about whether an executor was done.

The reap now writes the terminal row the completion path writes, in the same transaction
as the record update so the two cannot disagree, and behind a SAVEPOINT for the same
reason the completion path uses one: the reap is the accounting, the snapshot is a point
on a chart, and a failing INSERT must roll back only itself rather than leave the whole
fleet RUNNING. Identity comes off the record rather than the snapshot, so an executor
orphaned before its first snapshot gets a row too -- carrying the zeros the record itself
books, with SYSTEM_CLEANUP marking the close as approximated on both surfaces.

Reported by @david-hummingbot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qUvbCYgRroEM9JcXxQ1gx
update_bot_run_stopped wrote datetime.utcnow() -- correct in value, naive in type -- to a
TIMESTAMP(timezone=True) column, so the driver stored it as if it were already in the
session's local timezone and the row landed the server's UTC offset behind the real stop
time (8 hours, where this was reported).

stop-and-archive masked it: update_bot_run_archived runs afterwards with an aware value
and overwrites the wrong one. A bot stopped through plain POST /bot-orchestration/stop-bot
and never archived kept the skew indefinitely, and both run duration and the attribution
of performance-history windows to a run are read off this field.

PositionHold.last_updated had the same naive utcnow(), and while it is an in-memory field
the API serializes it with isoformat() beside values read back from the database, which
are aware -- so the same field reached a consumer labelled UTC on one path and unlabelled
on the other. Both now use datetime.now(timezone.utc).

A test walks every repository's AST for utcnow() calls, since nothing in the code or its
tests said the naive call was unsafe. The trailing whitespace this file carried throughout
is stripped so the lint hook can run on it.

Reported by @david-hummingbot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qUvbCYgRroEM9JcXxQ1gx
…l archive

An archived bot whose TradeFill table is empty -- every order rejected before it could
fill -- turned GET /archived-bots/{db}/performance and /summary into 500s with "cumsum is
not supported for object dtype". A zero-row read_sql_query gives back `object` columns,
pandas having nothing to infer a dtype from, and cumsum refuses object dtype however
empty the frame is.

Not a synthetic case: a bot whose per-level order notional sits below the exchange
minimum has every order rejected and archives in exactly this state, while /executors and
/orders on the same archive answer fine.

The scaled columns are now coerced to numeric before any arithmetic, in all three readers
that divide by 1e6 -- the trap is the same in each, and a stable dtype regardless of row
count is what the callers were already assuming. Coercion is per column because
DataFrame.apply never calls its function on a frame with no rows, which is precisely the
case at hand.

The file's unused sqlalchemy imports, trailing whitespace and two over-long lines go with
it, so the lint hook can run on it at all.

Reported by @david-hummingbot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qUvbCYgRroEM9JcXxQ1gx
@cardosofede

Copy link
Copy Markdown
Contributor Author

@david-hummingbot all three reproduced and fixed — aaba4b8, 6d2b723, e57e119. Full suite green, 816 passed.

1. The reap never wrote a terminal row (aaba4b8)

Exactly as diagnosed. cleanup_orphaned_executors only ever mutated the executors table, which made it the one path to TERMINATED with no terminal row behind it — so the invariant the /performance routes are built on held for every executor except the ones a crash caught, which is the least convenient possible exception.

The reap now writes the terminal row the completion path writes, in the same transaction as the record update, so the two surfaces cannot disagree about whether an executor is done. It reuses the completion path's SAVEPOINT discipline for the same reason that one has it: the reap is the accounting, the snapshot is a point on a chart, and a failing INSERT must roll back only itself rather than abort the reap and leave the whole fleet RUNNING.

Two details worth stating because they are decisions rather than mechanics:

  • The identity comes off the record, not off the last snapshot. An executor orphaned inside its first snapshot interval has no snapshot to read executor_type / connector_name / trading_pair from, and those columns are constant across an executor's life anyway.
  • An executor with no snapshot still gets a terminal row, carrying the zeros the record itself books. They are not fabricated figures — they are what /executors/{id} reports for that executor — and close_type: SYSTEM_CLEANUP marks the close as approximated on both surfaces, the same signal the record carries. The alternative was a gap, which is the state you just reported.

Four tests: the row is written with the record's post-adoption figures at closed_at, the identity comes from the record when there is no snapshot, the no-snapshot case still ends its series, and a failing snapshot insert leaves the reap committed.

Both route docstrings now say that a reaped executor's terminal row is a SYSTEM_CLEANUP approximated close, so the is_terminal: true / TERMINATED / last-observed-figures combination is documented rather than inferred.

2. stopped_at written naive against a TIMESTAMP(timezone=True) column (6d2b723)

datetime.utcnow() → datetime.now(timezone.utc). Your reading of the mechanism is right, including why stop-and-archive masks it: update_bot_run_archived overwrites the skewed value with a correct one, so only a stopped-and-not-archived run keeps it.

One more instance of the same class while I was in there: PositionHold.last_updated also used utcnow(). It is an in-memory field rather than a column, but the API serializes it with isoformat() in the same responses as values read back from the database — which are aware — so the same field reached a consumer labelled UTC on one path and unlabelled on the other. Both now use aware UTC.

Since nothing in the code or its tests said the naive call was unsafe, there is now a test that walks every repository's AST for utcnow() calls and fails on any of them. Every timestamp column in database/models.py is TIMESTAMP(timezone=True), so that rule has no exceptions to carve out.

3. Zero-fill archive 500s (e57e119)

Confirmed, and your read of the cause is exactly it: a zero-row read_sql_query yields object columns because pandas has nothing to infer a dtype from, and cumsum refuses object dtype however empty the frame is.

Fixed by coercing the scaled columns to numeric before any arithmetic — in all three readers that divide by 1e6 (get_trade_fills, get_orders, get_positions), because the trap is identical in each and a dtype that does not depend on row count is what every caller was already assuming. Coercion is per column rather than via DataFrame.apply, since apply never calls its function on a frame with no rows: precisely the case it would need to handle.

Tests build a real sqlite archive in your reported shape — rows in Order, none in TradeFill — and assert both routes answer instead of raising: /summary counts 1 order and 0 trades, /performance returns its "No trades found in database" body. Plus a with-rows case pinning that the scaling and the cumulative fees are unchanged, since a coercion that quietly altered a normal archive's numbers would be a worse bug than the one it fixes.

On the earlier POSITION_HOLD item

Still standing where we left it: the API keeps POSITION_HOLD unrealized and now says so in the schema and the route docstring, so the remaining change is Condor-side. Say the word and I'll file it there.

@david-hummingbot

Copy link
Copy Markdown
Contributor

Describe the bug

POST /backtesting/run fails 100% of the time in this environment with a generic, uninformative error:

{"detail":"Backtest worker exited with code 1 without producing a result"}

This happens even for a fully valid config against a real connector — the backtest computation itself completes successfully every time. The failure is in a completely separate step: writing the result to disk.

Root cause, confirmed via a full traceback: bots/data/backtests/ (the on-disk candle-cache/results directory this branch's PERF-112 introduces) is owned by root:root, while the API process runs as the host user (madcatz in this environment) — a very plausible outcome of this project's documented workflow of running infrastructure via docker-compose (where the API container itself runs as root and shares the bind-mounted ./bots:/hummingbot-api/bots volume) while also running the API directly on the host for local development. Every other subdirectory under bots/ (instances, archived, controllers, credentials, scripts, conf) is host-user-owned; only bots/data and bots/gateway-files are root:root.

Because of this, _worker_main's attempt to write its result envelope fails:

File "services/backtesting_service.py", line 242, in _worker_main
    with open(out_path, "wb") as fh:
PermissionError: [Errno 13] Permission denied: 'bots/data/backtests/.worker-<uuid>.pkl'

Compounding bug: that open(out_path, "wb") call sits outside _worker_main's try/except block:

def _worker_main(config, controllers_path, controllers_module, out_path) -> None:
    try:
        result = _run_backtest_blocking(config, controllers_path, controllers_module)
        blob = pickle.dumps({"ok": True, "result": result}, ...)
    except Exception as e:
        blob = pickle.dumps({"ok": False, "error": ..., "traceback": ...})
    with open(out_path, "wb") as fh:   # <-- not covered by the try/except above
        fh.write(blob)

So instead of the worker producing a {"ok": False, "error": ..., "traceback": ...} envelope that the API could surface as a specific, actionable 500 (which is exactly what CORR-101 in this same PR added the machinery for), the worker process crashes with an uncaught exception, exits with code 1, writes nothing, and the parent has zero information beyond "exited with code 1" — the real traceback only exists in the worker's stderr, which is not captured or logged anywhere the API or its caller can see (confirmed by redirecting stdout/stderr manually — see repro).

The candle-cache feature in the same PERF-112 commit already hits the identical permission error on bots/data/backtests/candles/ but handles it gracefully (falls back to running without cache, logged as a WARNING) — the result-file write is the one place this class of failure isn't guarded.

Steps to reproduce bug

  1. Confirm the ownership mismatch:
    ls -la bots/data/backtests/   # root:root
    ls -la bots/instances/        # host-user:host-user
  2. Run any backtest with a valid config:
    curl -s -w "\nHTTP:%{http_code}\n" -u admin:admin -X POST "http://localhost:8000/backtesting/run" \
      -H "Content-Type: application/json" \
      -d '{
        "start_time": 1735689600, "end_time": 1735776000,
        "backtesting_resolution": "1h", "trade_cost": 0.0006,
        "config": {
          "id": "bt_test", "controller_name": "pmm_simple", "controller_type": "market_making",
          "connector_name": "binance_perpetual", "trading_pair": "BTC-USDT", "total_amount_quote": 100,
          "buy_spreads": [0.01, 0.02], "sell_spreads": [0.01, 0.02],
          "buy_amounts_pct": [0.5, 0.5], "sell_amounts_pct": [0.5, 0.5]
        }
      }'
  3. To see the real traceback (invisible through the API), run the server with stdout/stderr captured to a file (e.g. uvicorn main:app --port 8001 > server.log 2>&1 &) and repeat step 2, then check server.log.

Example output

API response, every time, regardless of config validity:

{"detail":"Backtest worker exited with code 1 without producing a result"}
HTTP:500

Captured server log showing the real cause:

2026-09-09 00:57:33,945 - services.candles_cache - WARNING - Candle cache at bots/data/backtests/candles is unusable, running without it: [Errno 13] Permission denied: 'bots/data/backtests/candles'
Process SpawnProcess-1:
Traceback (most recent call last):
  File ".../multiprocessing/process.py", line 314, in _bootstrap
    self.run()
  File ".../multiprocessing/process.py", line 108, in run
    self._target(*self._args, **self._kwargs)
  File "services/backtesting_service.py", line 242, in _worker_main
    with open(out_path, "wb") as fh:
         ^^^^^^^^^^^^^^^^^^^^
PermissionError: [Errno 13] Permission denied: 'bots/data/backtests/.worker-fd7a12603ac044f09709cc19bd921ea9.pkl'

Calling _run_backtest_blocking directly (bypassing the worker subprocess entirely) with the identical config succeeds and returns a real result — proving the backtest logic itself is correct and the failure is isolated to the result-file write.

Provenance

Introduced by PR #226. git diff origin/main -- services/backtesting_service.py shows _worker_main — including the unguarded open(out_path, "wb") write that's the actual bug — as entirely new code (every line of the function appears as a + addition; no prior version existed on main). This is part of this branch's "run each backtest in its own killable worker process" work (PERF-112 and related). The trigger for the crash (the root-owned bots/data/ directory) is an environment/deployment artifact rather than code in any repo, but the fact that hitting it produces a silent, uninformative 500 instead of a clear error is new code this PR added.

Does this reproduce when hummingbot-api itself runs in Docker?

No — not this specific trigger. The Dockerfile has no USER directive, so the hummingbot-api container runs as root, and root bypasses host file-ownership checks entirely. Confirmed empirically:

$ docker run --rm -v $(pwd)/bots:/hummingbot-api/bots continuumio/miniconda3:latest \
    bash -c "whoami && touch /hummingbot-api/bots/data/backtests/.write-test && echo WRITE_SUCCEEDED"
root
WRITE_SUCCEEDED

So this exact symptom is specific to the environment it was found in: hummingbot-api run directly on the host (conda run uvicorn, as a non-root user) against a bots/ tree where bots/data/ had already been created root-owned by something else sharing that bind mount (almost certainly a hummingbot bot-instance container, which always runs as root). That's a plausible, and arguably common, situation — infra started via docker-compose while the API itself is run locally from source for hot-reload during development — but it will not appear for an all-Docker deployment.

That doesn't make the underlying code issue moot, though: _worker_main's result-write sitting outside its own try/except means any write failure there — disk full, a quota, a permissions quirk under some other orchestrator, an unusual mount — will still silently kill the worker with zero diagnostics ever reaching the API or its caller, regardless of deployment mode. The root-owned-directory case is simply the one that happened to be reproducible here.

@david-hummingbot

Copy link
Copy Markdown
Contributor

Describe the bug

POST /accounts/add-credential/{account_name}/{connector_name} leaks a raw Python AttributeError as the HTTP error detail whenever the request body omits a required credential field, instead of a clean validation message naming the missing field(s).

Root cause: UnifiedConnectorService.update_connector_keys (services/unified_connector_service.py) builds the connector config purely from whatever keys the caller supplied —

connector_config = HummingbotAPIConfigAdapter(
    AllConnectorSettings.get_connector_config_keys(connector_name)
)
for key, value in keys.items():
    setattr(connector_config, key, value)

— with no check that every field GET /connectors/{connector_name}/config-map marks "required": true was actually supplied. When a required field is missing, something downstream (BackendAPISecurity.update_connector_keys / decrypt_all) reads that never-set attribute and raises AttributeError, which the route's generic exception handler surfaces verbatim, including the internal Pydantic model class name (e.g. BinancePerpetualTestnetConfigMap).

Reproduces identically across every connector tried (binance_perpetual_testnet, binance, xrpl), so it's a generic gap in the credential-save path, not connector-specific.

Steps to reproduce bug

  1. Check the required fields for any connector:
    curl -s -u admin:admin "http://localhost:8000/connectors/binance_perpetual_testnet/config-map"
  2. Submit an empty (or partially filled) credentials body for that connector:
    curl -s -w "\nHTTP:%{http_code}\n" -u admin:admin -X POST \
      "http://localhost:8000/accounts/add-credential/master_account/binance_perpetual_testnet" \
      -H "Content-Type: application/json" -d '{}'

Example output

$ curl ... -d '{}'
{"detail":"'BinancePerpetualTestnetConfigMap' object has no attribute 'binance_perpetual_testnet_api_key'"}
HTTP:400

$ curl ... -d '{"binance_perpetual_testnet_api_key": "dummy"}'   # secret still missing
{"detail":"'BinancePerpetualTestnetConfigMap' object has no attribute 'binance_perpetual_testnet_api_secret'"}
HTTP:400

$ curl .../add-credential/master_account/binance -d '{}'
{"detail":"'BinanceConfigMap' object has no attribute 'binance_api_key'"}
HTTP:400

$ curl .../add-credential/master_account/xrpl -d '{}'
{"detail":"'XRPLConfigMap' object has no attribute 'xrpl_secret_key'"}
HTTP:400

Provenance

Pre-existing — not introduced by PR #226. git diff origin/main -- services/unified_connector_service.py shows no changes to update_connector_keys; git log -L on that function's line range shows its last modification was commit fc6eaf6 ("prevent double initialization"), unrelated to and predating this branch.

@david-hummingbot

Copy link
Copy Markdown
Contributor

Describe the bug

POST /controllers/{type}/{name}/config/validate (and, by the same code path, saving a config via POST /controllers/configs/{name} and deploying it) only checks that the submitted JSON satisfies the controller's Pydantic field shapes — it does not check that referenced connectors exist, and it does not enforce the same strict enum coercion the actual runtime uses. The result: a config that will deterministically crash the moment it is deployed is reported as "Configuration is valid", saved successfully, and deployed with "success": true, and only fails once the Docker container is already up and the strategy tries to start.

Two independent ways to trigger this, both reproduced:

1. Nonexistent connector. connector_name is validated as a plain string, never checked against the actual connector registry:

curl -s -X POST ".../controllers/market_making/pmm_simple/config/validate" -d '{"id":"t","controller_name":"pmm_simple","controller_type":"market_making","connector_name":"not_a_connector","trading_pair":"BTC-USDT","total_amount_quote":100}'
# -> {"message":"Configuration is valid"}

Saving and deploying this config succeeds at the API level (deploy-v2-controllers returns "success": true, a container is created), but the container crashes and exits within seconds:

connector_manager - Failed to create connector not_a_connector: 'not_a_connector'
trading_core - Failed to start strategy v2_with_controllers: 'not_a_connector'
root - Failed to load strategy. Exiting.

The resulting bot_runs row is left permanently stuck at deployment_status: "DEPLOYED", run_status: "CREATED", error_message: null — nothing ever reconciles it once the container is gone (see repro below).

2. Human-readable enum string instead of the numeric enum. The same gap applies to any TradeType-typed field (e.g. grid_strike's side): "side": "BUY" passes /validate and save, but the actual strategy load inside the container requires the raw enum int:

trading_core - Failed to start strategy v2_with_controllers: 1 validation error for GridStrikeConfig
side
  Input should be 1, 2 or 3 [type=enum, input_value='BUY', input_type=str]

(The same "BUY"-string-vs-numeric-enum mismatch also affects POST /executors/ directly — see the side field there — suggesting this is a systemic pattern anywhere a TradeType/similar enum is user-facing.)

Steps to reproduce bug (nonexistent connector → phantom bot_runs row)

  1. Validate and save a config with a bogus connector:
    curl -s -X POST "http://localhost:8000/controllers/market_making/pmm_simple/config/validate" \
      -H "Content-Type: application/json" \
      -d '{"id":"t","controller_name":"pmm_simple","controller_type":"market_making","connector_name":"not_a_connector","trading_pair":"BTC-USDT","total_amount_quote":100}'
    curl -s -X POST "http://localhost:8000/controllers/configs/qa_bogus_connector_test" \
      -H "Content-Type: application/json" \
      -d '{"id":"qa_bogus_connector_test","controller_name":"pmm_simple","controller_type":"market_making","connector_name":"not_a_connector","trading_pair":"BTC-USDT","total_amount_quote":100}'
  2. Deploy it:
    curl -s -X POST "http://localhost:8000/bot-orchestration/deploy-v2-controllers" \
      -H "Content-Type: application/json" \
      -d '{"instance_name":"qa_bogus_connector_bot","credentials_profile":"master_account","controllers_config":["qa_bogus_connector_test"],"image":"hummingbot/hummingbot:latest","headless":true}'
  3. Wait ~15s, then check the container and the bot_runs row:
    curl -s -u admin:admin "http://localhost:8000/docker/exited-containers"
    curl -s -u admin:admin "http://localhost:8000/bot-orchestration/bot-runs"

Example output

Deploy response (falsely reports success):

{"success":true,"message":"Instance qa_bogus_connector_bot-20260909-004931 created successfully.", ...}

Container state seconds later:

[{"name": "qa_bogus_connector_bot-20260909-004931", "status": "exited", "image": "hummingbot/hummingbot:latest"}]

The permanently-orphaned bot_runs row (unchanged no matter how long you wait, or even after manually removing the exited container):

{
  "id": 7,
  "bot_name": "qa_bogus_connector_bot-20260909-004931",
  "deployed_at": "2026-09-08T16:49:31.297332+00:00",
  "stopped_at": null,
  "deployment_status": "DEPLOYED",
  "run_status": "CREATED",
  "error_message": null
}

GET /bot-orchestration/bot-runs/stats also carries this row forever under status_counts.CREATED, permanently showing one bot "stuck" in a state that never resolves.

Related: config template misreports a required field

GET /controllers/{type}/{name}/config/template (the introspection endpoint meant to help a caller build a valid config) reports the id field as optional with a placeholder default, when /validate actually rejects a config that omits it:

$ curl -s .../controllers/market_making/pmm_simple/config/template | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])"
{"default": "PydanticUndefined", "type": "<class 'str'>", "required": false}

$ curl -s -X POST .../controllers/market_making/pmm_simple/config/validate -d '{"controller_name":"pmm_simple","controller_type":"market_making","connector_name":"binance_perpetual_testnet","trading_pair":"BTC-USDT","total_amount_quote":100}'
{"detail":"1 validation error for PMMSimpleConfig\nid\n  Field required [type=missing, ...]"}

The "default": "PydanticUndefined" value is Pydantic's internal missing-default sentinel, stringified directly into the JSON response rather than being translated into "required": true, "default": null — so a client following the template literally would omit id and get rejected.

Provenance

Pre-existing — not introduced by PR #226. git diff origin/main -- routers/controllers.py (which owns /validate and /config/template) shows zero changes — the file is byte-identical to main. git diff origin/main -- routers/bot_orchestration.py does show changes, but every hunk is scoped to stop_and_archive_bot (the READ-115 ownership-check fix); deploy_v2_controllers and deploy_v2_script fall outside every changed hunk and are untouched.

@david-hummingbot

Copy link
Copy Markdown
Contributor

Describe the bug

POST /docker/stop-container/{container_name} returns HTTP 200 even when the container does not exist, with a raw docker-py exception string as the response body instead of a clean 404/400 error.

{"detail": "Not found"}   <- expected, something like this

vs. what actually comes back:

"404 Client Error for http+docker://localhost/v1.55/containers/<name>/json: Not Found (\"No such container: <name>\")"

on HTTP 200. This leaks the internal docker socket URL and API version, and — more importantly — a caller checking only the status code (the normal way to detect failure) will see 200 and assume the stop succeeded.

Steps to reproduce bug

curl -s -w "\nHTTP:%{http_code}\n" -u admin:admin -X POST \
  "http://localhost:8000/docker/stop-container/this-container-does-not-exist"

Example output

"404 Client Error for http+docker://localhost/v1.55/containers/this-container-does-not-exist/json: Not Found (\"No such container: this-container-does-not-exist\")"
HTTP:200

Provenance

Pre-existing — not introduced by PR #226. git diff origin/main -- routers/docker.py is scoped entirely to remove_container (the READ-115 ownership-check fix) plus incidental whitespace/docstring cleanup elsewhere in the file; the stop_container route body is unchanged. git diff origin/main -- services/docker_service.py shows two changed hunks, both well after (line 174+) DockerService.stop_container (defined at line 129) — that method itself is untouched.

…not a 200

POST /docker/stop-container/{name} answered HTTP 200 for a container that does not
exist, with docker-py's exception string as the body. Two defects in one line: a caller
that checks the status code -- which is how a caller checks -- read a container that was
never stopped as one that had been, and the body published the daemon's socket URL and
negotiated API version ("404 Client Error for http+docker://localhost/v1.55/..."), which
is a detail of how this API talks to Docker rather than an answer to anything asked.

Every container operation in DockerService shared the shape `except DockerException as e:
return str(e)`, and every route returned it verbatim, so the same 200-with-an-error-string
answer came out of start-container, clean-exited-containers and both listing routes -- the
listings documented as returning a list.

DockerService now separates NotFound from the rest, logs the raw docker-py error, and
returns the kind of failure with a message that names only the container. routers/docker.py
maps that to a status code in one place: 404 for a container that is not there, 502 for a
daemon that refused or could not be reached. A successful stop or start now says so
instead of returning null, which was itself indistinguishable from a failure body.

Failures stay in-band rather than becoming exceptions because stop-and-archive calls
stop_container in a retry loop and decides whether it worked by reading the container's
status afterwards; raising would abort a workflow built to tolerate a stop that did not
take. remove-container keeps its 200 for the same class of reason, called out at the call
site: that route also archives, and a container already gone is a bot whose data still
needs archiving.

Reported by @david-hummingbot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qUvbCYgRroEM9JcXxQ1gx
@cardosofede

Copy link
Copy Markdown
Contributor Author

@david-hummingbot the stop-container one is fixed in 1977383. Full suite green, 829 passed.

Both halves of it confirmed. A missing container now answers 404 with No such container: <name>, and a daemon that refuses or cannot be reached answers 502 — the raw docker-py string, socket URL and API version included, goes to the log where it belongs. A successful stop now returns {"success": true, "message": ...} rather than null, which was itself indistinguishable from an error body.

I took the whole shape rather than the one route. Every container operation in DockerService was except DockerException as e: return str(e), and every route returned it verbatim, so start-container, clean-exited-containers and both listing routes gave the same 200-with-an-error-string answer — the listings while documenting a list as their return type. DockerService now separates NotFound from the rest and reports the kind of failure; routers/docker.py maps kind to status code in one place.

Two decisions worth stating, since both are cases where the obvious fix would have been wrong:

Failures stay in-band rather than becoming exceptions. stop_and_archive_bot calls stop_container in a ten-attempt retry loop and decides whether it worked by reading the container's status afterwards — it is built to tolerate a stop that did not take. Raising would abort that workflow at the first attempt, so the service returns the failure and the HTTP layer is what turns it into a status code.

remove-container deliberately keeps its 200, called out at the call site. That route also archives, and a container that is already gone is a bot whose data still needs archiving — a removal that finds nothing to remove has reached the end state the caller asked for. Its response still carries success for a caller that wants to know which of the two happened. Happy to make it 404 if you would rather it be strict, but that would mean a bot whose container was removed out from under the API can no longer be archived through this route.

13 tests, driven by the exception docker-py actually raises (socket URL and v1.55 in the message), including an explicit assertion that neither string can reach a response body, and one pinning the in-band contract the retry loop depends on.

The other three

Not touched yet — say which you want first and I'll take them in order:

  • Backtest worker's unguarded result write. The environment trigger is yours to keep or fix, but you are right that the open(out_path, "wb") outside _worker_main's try/except is this PR's code and is what turns any write failure into exited with code 1 with no diagnostics. That one is squarely mine.
  • add-credential leaking AttributeError. Pre-existing, as you found, but the fix is small: check the supplied keys against the connector's own required fields before building the config.
  • /config/validate accepting a config that cannot run. The largest of the three — connector-registry validation plus strict enum coercion, and the phantom bot_runs row left behind by a container that dies on startup is arguably a separate reconciliation fix.

@cardosofede
cardosofede merged commit 7e86651 into main Sep 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants