Compare commits

...

191 Commits

Author SHA1 Message Date
Dhruv Gupta 08285468e0 release: v0.5.1
Publish images (public) / build-and-push (push) Has been cancelled
GitHub Release / draft-release (push) Has been cancelled
Publish images (public) / generate-sbom (push) Has been cancelled
Publish images (public) / reconcile-floating (push) Has been cancelled
2026-07-10 22:56:05 +00:00
Zeyi (Rice) Fan 86088d6a8a fix(web): hide embedded browser on desktop shells that lack it (#2393)
## Related issue

N/A

## Summary

- The web app gated the embedded-browser feature on `isElectronShell()`
  — "am I in any Electron shell?". Older, already-installed desktop
  builds whose preload predates the `browser*` bridge return true there,
  so they surfaced a Browser tab that did nothing: the pane and agent
  relay called `browserOpenOrNavigate` on a bridge without that method
  and silently no-op'd.
- Add `supportsBrowser()` to `nativeBridge.ts`, which probes for the
  `browserOpenOrNavigate` capability marker (the whole `browser*` suite
  ships together). This follows the module's established feature-based
  detection idiom and is the only approach that works retroactively for
  shells already in the field, since they expose no version.
- Swap the browser-feature gates from `isElectronShell()` to
  `supportsBrowser()`: the `railTabsAvailable.browser` tab gate and the
  auto-surface / design-mode effects in `AppShell.tsx`, both relay gates
  in `useBrowserAgentRelay.ts` (so an old shell never claims a browser
  action it can't fulfill), and the `BrowserPane` bridge + self-gate.
- Leave the non-browser `isElectronShell()` sites (host status, Local
  CLI settings) untouched.

## Test Plan

- `cd web && npx vitest run` on the affected suites (nativeBridge,
  BrowserPane, useBrowserAgentRelay): 70/70 pass.
- Full single-threaded `vitest run`: 3951 pass; the only 2 failures are
  in `ChatPage.composer.test.tsx`, confirmed pre-existing on the clean
  base (identical with and without this change).
- `tsc -p tsconfig.app.json --noEmit`: clean for the touched files (the
  `@xyflow/react` errors are a pre-existing missing-dep in an untouched
  file).
- Manual: user verified the Browser tab shows on the current desktop
  build and hides when the browser bridge is absent.

## Type of change

- [ ] Bug fix
- [x] Feature
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Added `supportsBrowser` unit cases in `nativeBridge.test.ts` (false in a
plain browser, false on an Electron shell lacking the browser method,
true when present, false under iOS) and updated the BrowserPane / relay
test mocks to export it. Manually verified end-to-end by the user: the
Browser tab appears on a current desktop build and disappears when the
`browserOpenOrNavigate` bridge method is absent.
2026-07-10 22:55:05 +00:00
Dhruv Gupta 1d1b3a8605 release: v0.5.0
GitHub Release / draft-release (push) Has been cancelled
Publish images (public) / build-and-push (push) Has been cancelled
Publish images (public) / generate-sbom (push) Has been cancelled
Publish images (public) / reconcile-floating (push) Has been cancelled
2026-07-10 21:03:41 +00:00
Dhruv Gupta 3d91adf903 release: v0.5.0rc2
GitHub Release / draft-release (push) Has been cancelled
Publish images (public) / build-and-push (push) Has been cancelled
Publish images (public) / generate-sbom (push) Has been cancelled
Publish images (public) / reconcile-floating (push) Has been cancelled
2026-07-10 20:43:54 +00:00
Bryan Qiu 3cc8bb205f feat(sharing): add OMNIGENT_SHARING_MODE (#1835)
* feat(sharing): add OMNIGENT_SHARING_MODE server gate (on / read_only / off)

Adds a tri-state session-sharing policy to create_app, defaulting from
the top-level OMNIGENT_SHARING_MODE env var (on / read_only / off) and
failing open to ON. When off, grant_permission is rejected (403) and the
SPA shows a "sharing disabled" dialog; when read_only, new grants are
capped at read (edit/manage rejected) and the Share modal offers only
read. GET /v1/info reports sharing_mode so the web app gates its Share
controls to match. Revoke/list and self-ownership grants are unaffected
in every mode.

Also accepts a static SharingMode or a per-request callable, so a
deployment can flip the policy at runtime (e.g. a Databricks SAFE flag)
without a restart.

Tests: 29 new server tests (coerce fail-open, create_app wiring incl.
the env var, /v1/info, and the 403/200 grant gate against a seeded
store) plus 3 web tests for the modal's off / read_only / on states.

Co-authored-by: Isaac

* feat(sharing/web): gray out Share affordances when sharing_mode is off

Extends the existing shareDisabled pattern so both the ChatHeader Share
button and the sidebar row's Share menu item render disabled (with a
tooltip) when /v1/info reports sharing_mode "off". read_only keeps them
enabled — the modal caps the grant level. Fails open (enabled) while the
capability probe is still loading.

Existing collaboration surfaces ("Shared with me", presence, fork) are
intentionally untouched: turning sharing off blocks *new* grants but does
not revoke existing access, so those must keep working.

Adds AppShell + Sidebar.rowActions tests for the off (disabled) and
on / read_only (enabled) states.

Co-authored-by: Isaac

* feat(sharing): add restricted_read_only tier (blocks home/root-cwd sessions)

Adds a fourth OMNIGENT_SHARING_MODE tier, restricted_read_only: it caps new
grants at read like read_only, but additionally rejects ALL grants (even read)
on a session whose working directory is a user home directory or the filesystem
root — that cwd exposes an entire home/filesystem, so it must not be shared.

- auth.py: SharingMode.RESTRICTED_READ_ONLY + workspace_sharing_blocked() helper
  (recognizes /, /root, direct children of /home and /Users, and the server's
  own ~; subdirectories of a home and an unset cwd stay shareable).
- routes/sessions.py: the grant gate looks up the session workspace and 403s a
  home/root-cwd session entirely; other sessions fall through to the read cap.
- web: capabilities.ts recognizes the value; the Share modal presents the same
  read-only UI as read_only. The per-session home/root block is enforced
  server-side and surfaces as an error on the grant attempt.

Tests: coerce + /v1/info round-trip the new value, a workspace_sharing_blocked
truth table, and the gate (home/root cwd -> 403 even read; normal cwd -> read
ok / edit 403; no cwd -> read ok), plus a modal test for the read-only UI.

Co-authored-by: Isaac

* feat(sharing): admin panel control for the server-wide sharing mode

Makes OMNIGENT_SHARING_MODE runtime-configurable from Settings → Sharing, so an
admin can pick among the four tiers (on / read only / read only restricted /
off) without a redeploy. The env var remains the boot default; the admin choice
is a per-server override that wins when set.

Persistence follows the OSS operator-editable-state convention (no DB
migration): the override lives in <data_dir>/sharing_mode next to the admins
roster, read mtime-cached per request so a change takes effect immediately and
survives restarts.

- server/sharing_settings.py: file-backed override read/write (atomic,
  mtime-cached), falling back to the env default when unset/unrecognized.
- server/app.py: the create_app default resolver now reads override-else-env
  and marks app.state.sharing_mode_writable; an explicit static/callable mode
  (managed/embedded, e.g. a SAFE flag) stays authoritative and non-editable.
- routes/sharing_mode.py: admin-gated GET/PUT /v1/sharing-mode reporting the
  current mode + an `editable` flag + the tiers; PUT strictly validates (400 on
  an unknown value, no fail-open) and 403s when not file-backed.
- web: a new admin-only Settings → Sharing section (SharingPage + useSharingMode
  hooks + settingsNav entry) with a 4-tier picker, read-only when the server
  reports editable:false.

Tests: file-override roundtrip + create_app precedence over the env default, the
admin route (GET state, PUT persist reflected in /v1/info and the gate, 400 on
unknown, 403 for non-admin and for a deployment-managed mode), and a SharingPage
suite (tiers render, choosing calls the mutation, read-only notice, non-admin
gate).

Co-authored-by: Isaac

* feat(sharing): add OMNIGENT_PUBLIC_SHARING switch for public (link) access

Adds a server-wide switch for public (anyone-with-the-link) read access,
independent of the sharing tiers: an org can keep normal user-to-user sharing
on while disabling public links. Controlled at the top level by the
OMNIGENT_PUBLIC_SHARING env var (default enabled, fails open) and, like the
sharing mode, overridable at runtime from Settings → Sharing.

When disabled, granting the __public__ sentinel is rejected (403), /v1/info
reports public_sharing_enabled: false, and the Share modal hides the "Public
access" toggle. User-to-user grants are unaffected.

- sharing_settings.py: file-backed public_sharing override (<data_dir>/
  public_sharing) + env default parse, sharing the mtime-cached reader with the
  sharing_mode override (cache refactored to a per-path dict).
- app.py: create_app gains a `public_sharing` param (bool / callable / None),
  normalized to app.state.public_sharing + a public_sharing_writable flag;
  /v1/info reports public_sharing_enabled.
- routes/sessions.py: the grant gate rejects a __public__ grant when public
  sharing is off, independent of the sharing_mode gate.
- routes/sharing_mode.py: GET now also reports public_sharing_enabled +
  public_sharing_editable; PUT accepts an optional public_sharing boolean
  (each field independently writable, 400 when the body updates nothing).
- web: capabilities.ts carries public_sharing_enabled (fail-open true); the
  Share modal hides the public toggle when off; the Sharing admin page gains a
  "Public access" switch (read-only when deployment-managed).

Tests: server coverage for the env default / static / file-override wiring,
the public grant gate (blocked when off, user grants still allowed), /v1/info
reporting, and the admin GET/PUT (persist, reflected in /v1/info and the gate,
403 when not writable); web tests for the modal hiding the toggle and the
admin page's public switch.

Co-authored-by: Isaac

* test(sharing): regenerate openapi.json + update Admin-nav test

CI drift from the sharing work:
- openapi.json was stale — regenerated via scripts/dump_openapi.py to include
  the /v1/sharing-mode GET/PUT routes and the SetSharingModeRequest body
  (sharing_mode + public_sharing). Fixes test_openapi_json_matches_generator_output.
- settingsNav.test.tsx asserted the Admin group was exactly [members, policies];
  the Sharing section added a third item. Updated the expectation to
  [members, policies, sharing].

Co-authored-by: Isaac

* refactor(sharing): host-agnostic workspace block + rename endpoint to /v1/sharing

Addresses PR review:

#4 — workspace_sharing_blocked no longer resolves the server process's ``~``
(meaningless on a remote runner whose home lives on another host). It now
matches purely on path shape and covers the common home layouts: the
filesystem root (/), root's home (/root), and any direct child of /home,
/Users, or /var/home (ostree). Project-workspace roots (/workspace,
/workspaces/<repo>) are deliberately NOT blocked — they hold a single
checkout, not a whole home. Tests updated accordingly (drops the ~ case, adds
/var/home + a /workspaces project-dir shareable case).

#5 — the admin endpoint/resource now governs two settings (mode + public
access), so ``/v1/sharing-mode`` → ``/v1/sharing``, object ``"sharing_mode"``
→ ``"sharing"``, create_sharing_mode_router → create_sharing_router,
SetSharingModeRequest → SetSharingRequest, and the web hook useSharingMode.ts
→ useSharing.ts (useSharing / useSetSharing, SharingState / SharingUpdate).
The response's ``sharing_mode`` field (the tier value) and the SharingMode
enum are unchanged. openapi.json regenerated.

Co-authored-by: Isaac

* refactor(sharing): atomic admin PUT + docstring/copy accuracy

Follow-up on PR review:

- routes/sharing.py: validate AND authorize both fields before writing either,
  so a both-fields PUT where only one setting is file-backed (mode editable,
  public deployment-managed, or vice-versa) can no longer persist one override
  and then 403 on the other. Adds test_admin_put_is_atomic_across_mixed_
  writability (403 + the writable half is not persisted).
- app.py: create_app docstrings — sharing_mode now lists restricted_read_only;
  public_sharing describes the env var as "enabled unless explicitly falsy
  (0/false/no/off)" (matching public_sharing_env_default, not env_var_is_truthy)
  and notes existing public grants are unaffected.
- SharingPage.tsx: surface the non-retroactive behavior — changes affect only
  new shares; existing grants (including already-public sessions) keep working
  until revoked.

Co-authored-by: Isaac

* test(sharing): e2e_ui share-button gray-out + harden grant-gate state reads

- sessions.py (#2 from review): the grant gate now reads app.state via
  getattr(..., default) — getattr(request.app.state, "sharing_mode",
  lambda: SharingMode.ON)() and the public equivalent — so a router mounted
  without create_app (a focused test) can't AttributeError. Behavior-preserving
  for every production path (create_app always sets both).
- tests/e2e_ui/collaboration/test_sharing_mode_off.py: a Playwright test for
  the server-side kill switch surfacing in the SPA. Spins up a dedicated server
  with OMNIGENT_SHARING_MODE=off (the shared live_server is session-scoped/on,
  and the admin route is admin-gated for the headerless local identity),
  creates a session, and asserts the header Share button is disabled with the
  "Sharing has been disabled…" tooltip — served via the public-loopback alias
  so the local-server disable doesn't mask it. Mirrors the assertion shape of
  test_permissions_modal.py::test_local_server_disables_share_button_with_tooltip.

Co-authored-by: Isaac
2026-07-10 20:43:11 +00:00
Zeyi (Rice) Fan 9567ce3c74 Add zhengwin to maintainer (#2384) 2026-07-10 20:43:11 +00:00
Pat Sukprasert 5defa1955f fix(harnesses): flow Anthropic gateway creds host→runner→Claude Code launch (#2371)
* fix(harnesses): flow Anthropic gateway creds host→runner→Claude Code launch

A browser-created managed sandbox running claude-native against an
Anthropic-compatible gateway (e.g. LiteLLM) needs ANTHROPIC_API_KEY,
ANTHROPIC_BASE_URL, and ANTHROPIC_MODEL to survive three hops. Each hop
dropped or ignored the model / gateway wiring, so sessions failed with
invalid-model or auth errors, or hung on Claude Code's custom-key menu.

- Host→runner env: forward ANTHROPIC_MODEL through the harness credential
  allowlist next to ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL, so the runner
  no longer resolves model=None.
- Ambient provider synthesis: an ambient ANTHROPIC_API_KEY now honors
  companion ANTHROPIC_BASE_URL and ANTHROPIC_MODEL, mirroring the OpenAI
  branch, so a gateway key routes to the gateway with the served model
  pinned instead of api.anthropic.com with no model.
- Native launch + tmux delivery: when an apiKeyHelper delivers the
  credential, strip the raw ANTHROPIC_API_KEY (and CLAUDECODE) from the
  Claude terminal child so Claude Code doesn't open its custom-API-key
  menu, and teach the prompt-readiness scan to ignore selected numbered
  menu rows so the first web message isn't typed into that menu.

Co-authored-by: omnigent <noreply@omnigent.ai>

* test(harnesses): pin apiKeyHelper no-raw-key invariant, fail loud

The helper-path key strip in the Claude terminal env relies on
build_native_claude_terminal_env never emitting a raw ANTHROPIC_API_KEY
when an apiKeyHelper is configured. If a future change starts injecting
the raw key on that path, it would silently reintroduce Claude Code's
custom-API-key menu hang. Raise at the env-build seam when the invariant
breaks, and pin it with a focused unit test.

Co-authored-by: omnigent <noreply@omnigent.ai>

* test(harnesses): pin Databricks-gateway helper-path env shape

Existing helper-path coverage is generic gateway-shaped; add a test for
the Databricks ucode/profile case real users run. Through
_claude_terminal_env_unset and the terminal-env build, assert the child
drops DATABRICKS_CONFIG_PROFILE and the raw key / nested-session marker
while apiKeyHelper, ANTHROPIC_BASE_URL, and the gateway model survive, so
Claude Code still authenticates against Databricks.

Co-authored-by: omnigent <noreply@omnigent.ai>

* docs(harnesses): trim comments on the Anthropic gateway cred path

Tighten the comments and docstrings introduced by this branch to match
the repo's comment guidance: keep them short and focused on the scenario,
drop redundant restatement, and remove paragraphs that duplicate a nearby
docstring. Preserve the load-bearing "why" — the Databricks profile drop
at the terminal-child hop, the apiKeyHelper raw-key guard, and the
readiness-scan menu-glyph rationale.

Comment-only; no executable code changed.

Co-authored-by: Isaac

* 🐛 fix(harnesses): Strip nested Claude marker

* 🐛 fix(harnesses): Recognize numbered Claude drafts

---------

Co-authored-by: omnigent <noreply@omnigent.ai>
2026-07-10 20:43:11 +00:00
Dhruv Gupta 25b8e200f3 release: v0.5.0rc1
Publish images (public) / build-and-push (push) Has been cancelled
GitHub Release / draft-release (push) Has been cancelled
Publish images (public) / generate-sbom (push) Has been cancelled
Publish images (public) / reconcile-floating (push) Has been cancelled
2026-07-10 18:59:58 +00:00
dosenr 713573cceb test: raise the runner-connect budget in the external-runner integration test (#2227)
The 10s online-poll budget flakes when a loaded CI worker starves the runner
process. Hard cap only, not a behavior assertion: the loop exits the moment
the runner reports online, so only starved workers ever use the tail.

The interrupt-forward test this PR originally also touched was fixed better
in #2232 (direct awaits under pytest's global timeout); that hunk is dropped.

Signed-off-by: dosenr <robert.dosen@gmail.com>
2026-07-10 14:26:37 +02:00
Arshdeep singh 3526e2b64f fix: prioritize sessionModelOverride in AgentPicker display (#1513)
* fix(ui): prioritize sessionModelOverride in AgentPicker display

* test(ui): cover session model override picker priority

* style(ui): format model picker e2e test

* fix(ui): preserve vendor model picker selection

---------

Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-10 12:01:13 +00:00
Pat Sukprasert 60b5f9ac9f feat(harness-bench): probe native policy actions (#2370)
*  feat(harness-bench): Probe native policy actions

- Exercise explicit ALLOW and ASK through native policy hooks\n- Resolve ASK elicitations and clean up temporary session policies\n- Cover policy lifecycle and capability verdicts offline

* 🐛 fix(harness-bench): Clean up policy readers

- Stop native ALLOW stream readers on terminal events\n- Record ASK elicitation ids before publishing the observed flag\n- Clarify that native ALLOW measures non-blocking under an attached policy
2026-07-10 11:55:02 +00:00
Serena Ruan 7aace8eb7f chore(ci): remove Kecheng from Discord watch rotation (#2367)
Co-authored-by: Isaac
2026-07-10 19:06:44 +08:00
Pat Sukprasert 0540942062 fix(host): non-editable install sibling SDKs (#2361)
Reinstall the bundled Python client and UI SDK non-editably in the host image so Landlock-sandboxed imports do not resolve through /build. Keep the existing root package reinstall and add a build-time check that .pth/.egg-link files no longer reference /build.

Co-authored-by: omnigent <noreply@omnigent.ai>
2026-07-10 17:57:21 +07:00
Serena Ruan d74984330c perf(search): keep snippet fetch on the conversation_items index (#2365)
_fetch_search_snippets filtered and joined on conversation_id + position
but omitted workspace_id — the leading column of the only covering index
(workspace_id, conversation_id, position). Without it Postgres can't use
the index and full-scans every conversation_item to fetch the 20 snippet
bodies for a search page, so the snippet fetch alone roughly doubled
search latency and grew with total corpus size.

Add workspace_id to both the MIN(position) aggregate and the join-back so
both stay on the composite index. On a 5k-session / 1M-item Postgres
corpus this drops the snippet query from ~430-680ms (Seq Scan) to ~7ms
(Index Scan), and the search_sessions benchmark P50 from ~571ms to
~315ms. No behavior change — same rows, same earliest-match snippet.

Co-authored-by: Isaac
2026-07-10 18:52:51 +08:00
Yuan Tang 10532c9d6f fix(web): select-all only selects sessions in expanded sidebar sections (#2311)
* fix(web): surface server error message in stop-session dialog

The stop-session dialog previously showed a hardcoded message on
failure. Now it displays the actual error from the API response
(e.g. "503 Service Unavailable") so users can diagnose the issue
without opening developer tools.

* fix(web): select-all only selects sessions in expanded sidebar sections

Previously, "Select all" in bulk-selection mode selected every loaded
session including archived and collapsed ones. Now it respects section
collapse state, matching the visible rows.

* fix(web): lift visibleConversations to Sidebar via ref getter

visibleConversations was defined inside ConversationList but referenced
in the parent Sidebar component, causing a ReferenceError at runtime.
Use the same ref-getter pattern as getVisibleIdsRef so the child
populates the getter and the parent calls it on demand.
2026-07-10 10:52:07 +00:00
Pat Sukprasert 4ab0216bb0 perf(harness-bench): tighten native timeouts so broken harnesses fail fast (#2366)
A full-matrix native run spent minutes in dead waits: a broken vendor forwarder
burned the full 90s _FORWARDER_READY budget before SKIPping (kimi/hermes), and a
model that stalled a turn burned the full 180s _TURN/_TOOL budget. These are
"clearly stuck" ceilings, not expected durations — provisioning is local
(server/runner/host/forwarder boot, no model call) and a healthy native turn
streams within seconds, so a run that blows them is a cold-start on a slow CLI
or a connection/network problem, not normal latency.

Halve them, keeping cold-start headroom:
- _TURN_TIMEOUT_S / _TOOL_TURN_TIMEOUT_S 180 -> 60
- _FORWARDER_READY_TIMEOUT_S 90 -> 45 (and the terminal-ensure HTTP timeout now
  references it instead of a separate hardcoded 90)
- _HEALTH_TIMEOUT_S 90 -> 45 (native + full_server)
- _HOST_ONLINE_TIMEOUT_S 45 -> 30
- _DENY_OBSERVE_S 30 -> 15 (post-tool-call grace window for policy_denied)

Worst case for a broken harness drops from ~90-180s to ~45-60s per stall; a
whole-harness provisioning failure now fails in ~45s instead of 90s. Healthy
runs are unaffected (they finish well under the new ceilings). Live gated
full-server tests keep their explicit timeout=180 (real gateway turns).

114 passed / 18 skipped; ruff clean.

Co-authored-by: Isaac
2026-07-10 17:38:52 +07:00
Pat Sukprasert 531931f95c docs(harness-bench): update shipped status (#2364) 2026-07-10 18:15:50 +08:00
Pat Sukprasert 7b2871da8c refactor(harness-bench): reuse shared runtime helpers (#2354)
* refactor(harness-bench): reuse shared runtime helpers

- expose config loading without coupling the bench to CLI internals
- centralize session item parsing and full-server polling
- reuse the shared live-server port helper and add focused tests

* refactor(harness-bench): trim redundant comments

* fix(harness-bench): preserve config semantics
2026-07-10 18:05:29 +08:00
Yuan Tang 766fd26226 feat(policies): show model checkboxes for expensive_models in policy dialogs (#1537)
* feat(policies): show model checkboxes for expensive_models in policy dialogs

The expensive_models field in cost-budget policies was a free-text input
requiring users to type comma-separated model tokens. Populate it with
checkboxes from the existing model lists (CLAUDE_NATIVE_MODELS and
session-scoped codexModelOptions) so users can select models visually.

* style: fix prettier formatting in PoliciesPage

* fix: widen modelIds type to satisfy strict const array check

* fix: add missing useMemo import and type annotations in AgentInfo

* feat(policies): replace model checkboxes with dropdown + free-form input

Address reviewer feedback: show known models in a dropdown for quick
selection while also providing a free-form text input for adding custom
model IDs not in the predefined list. Selected values appear as
removable tags.

* feat(policies): themed multi-select combobox for model array params

Replace the native <select> + separate free-text box for array params
(e.g. expensive_models) with a single themed combobox. Users type a
free-form value or pick from a dropdown of existing models; selected
values show a checkmark and toggle on click, and render as removable
chips. The dropdown renders in normal flow inside the dialog so it
scrolls with the modal instead of overlapping the buttons or being
clipped.

The form still stores a comma-joined string and coerces to list[str]
on submit, so the wire format and free-form entry are unchanged.

Add tests covering the combobox in isolation and end-to-end through
both the per-session and global add-policy dialogs, guarding the
coerced list[str] payload against regression.

Co-authored-by: Isaac

---------

Co-authored-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-10 17:59:50 +08:00
Serena Ruan 60e775a267 feat(search): show matched-content preview in session search (#2162)
* feat(search): show matched-content preview in session search

Session search already matched on title OR conversation item content,
but GET /v1/sessions returned only session rows, so the command palette
could show only the title — a content match was invisible ("why did this
match?"). Surface a short excerpt of the matching chat text so the UI can
show *where* a session matched.

- build_search_snippet (db/utils): windows ~60 chars around the first
  match, collapses whitespace, elides ends with "…"; never clamps the
  match term out of the window.
- Conversation gains a transient search_snippet (never persisted).
- list_conversations, on a content search, bulk-builds one snippet per
  matched conversation via a MIN(position) subquery join (earliest turn
  wins; one row per conversation, no N+1). Title-only matches stay None.
- SessionListItem.search_snippet + populated in the shared list builder;
  exclude_none keeps it off the wire for title-only matches.
- Command palette renders the snippet as a dimmed second line and bolds
  the query term (regex-escaped) in both title and snippet.

Co-authored-by: Isaac

* fix(search): keep the palette match preview from flickering on stream ticks

search_snippet is a search-only field — only GET /v1/sessions?search_query=
computes it. But the WS /v1/sessions/updates stream patches the same cached
rows, and its dump had no query in flight, so it emitted search_snippet: null
and clobbered the snippet the search response had put in the cache. The preview
then vanished on the next stream tick (~60s or any session change), which is
why the highlight showed up only sometimes.

Exclude search_snippet from the watched-items dump so the key is absent from
the frame: the cache merge then leaves the cached snippet untouched. The GET
search path is unchanged (still emits it via exclude_none).

Co-authored-by: Isaac
2026-07-10 17:53:50 +08:00
Serena Ruan adf04793cf fix(ci): pin rotation workflow actions to commit SHAs (#2363)
The org requires all GitHub Actions to be pinned to a full-length commit
SHA; actions/checkout@v4 and actions/setup-python@v5 were rejected at
run time. Pin both to the same SHAs the repo's other workflows use.

Co-authored-by: Isaac
2026-07-10 17:45:59 +08:00
Serena Ruan 1141dc3973 feat(ci): add Discord watch rotation Slack reminder (#2197)
* feat(ci): add Discord watch rotation Slack reminder

Add a deterministic daily on-call reminder that pings the person on
Discord-watch duty in Slack at 08:00 their local time. A hosted GitHub
Actions cron runs the script; whose turn it is is a pure function of the
date, so there is no state to store.

- Weekday-only rotation that advances by workdays (Fri hands off to Mon).
- Per-person timezone: SF folks pinged at 8am PT, Singapore at 8am SGT.
- Manual OOO spans with skip-and-cover (next available person covers).
- Dry-run when SLACK_WEBHOOK_URL is unset (prints instead of posting).

Co-authored-by: Isaac

* fix(ci): restrict GITHUB_TOKEN to contents:read in rotation workflow

CodeQL flagged the workflow for not limiting GITHUB_TOKEN permissions.
The job only checks out the repo and runs a script, so grant the minimal
contents: read and nothing else.

Co-authored-by: Isaac

* fix(ci): redact webhook URL from rotation post errors

A bare urlopen lets urllib's exception stringify the full webhook URL,
which would land in the Actions log on any POST failure. Wrap the call
and re-raise a SlackPostError carrying only the HTTP status / reason, so
the secret never appears in logs or error output.

Co-authored-by: Isaac

* refactor(ci): simplify rotation morning check to a band

Replace the exact 7/8am hour check with a "morning band" (05:00–11:59
local): ping the day's assignee only when it's currently morning where
they live, otherwise the run for their timezone's morning covers them.

This drops the DST special-casing and, more importantly, tolerates
GitHub's frequently-delayed cron schedule — a run up to ~3 hours late
still lands in the band instead of silently skipping the day. The band
starts at 05:00 rather than midnight so a delayed cron from the other
timezone spilling past local midnight can't be mistaken for this
timezone's morning and double-ping.

Co-authored-by: Isaac

* feat(ci): always report today's watch on rotation runs

The morning-band check gated even the dry-run output, so a manual
workflow_dispatch outside anyone's window just printed "nobody's on
watch" — unhelpful for a button meant for testing. Log today's assignee
per timezone unconditionally before the gate, so a manual run is always
informative; pinging still only happens inside the morning window.

Co-authored-by: Isaac
2026-07-10 17:40:14 +08:00
Pat Sukprasert 164a46eee9 fix(tests): give each xdist worker its own snapshot_failures dir (#2353)
* ci(images): make the Docker build check a required merge gate

The build-only PR check added in #2288 has proven fast (~1m28s cache-cold)
and reliable, so promote it from report-only to a blocking merge gate.

- required.sh: add "Docker build" to REQUIRED, and to ALLOW_SKIP with a
  workflow_for() arm so a PR whose paths filter skips the build (nothing
  image-relevant changed) doesn't strand the gate — a missing check is
  treated green only when its workflow legitimately didn't run.
- merge-ready.yml: add "Docker build" to the workflow_run list so the gate
  re-evaluates when the build completes.

Safe for fork / non-maintainer PRs: the check builds with push:false (no
secrets, no registry) and already runs behind the security gate, so it
behaves identically to a maintainer PR.

Co-authored-by: Isaac

* fix(tests): give each xdist worker its own snapshot_failures dir

The pytest-playwright-visual-snapshot plugin's session-scoped autouse
cleanup_snapshot_failures fixture runs in every pytest session — including
the non-visual unit shards — and rmtree->mkdir's a single static path. Under
xdist, all workers race on that one path: the non-atomic rmtree/mkdir lets
one worker's mkdir(exist_ok=True) re-raise FileExistsError when another
deletes the dir in the window, and that fixture error cascades to every test
on the worker (47 spurious failures in the runtime-core shard on CI run
29072231637).

Override the fixture in the root tests/conftest.py so it keys the failures
leaf off PYTEST_XDIST_WORKER (snapshot_failures/gwN). No two workers ever
touch the same directory, so the race is gone by construction — no retries
or sleeps. The shared parent is only ever created, never deleted, so the
plugin's delete-then-create-the-same-dir window cannot recur. Without xdist
(the serial ui-snapshot.yml gate) the worker id is unset and the base path
is used unchanged.

Co-authored-by: omnigent <noreply@omnigent.ai>

---------

Co-authored-by: omnigent <noreply@omnigent.ai>
2026-07-10 09:10:00 +00:00
Zeyi (Rice) Fan 476beffd3c feat(omnidev): give each dev pod its own isolated config.yaml (#2360)
Each omnidev dev pod now gets its own config.yaml under <pod>/config/,
pointed to by OMNIGENT_CONFIG_HOME (which omnigent's server/host/runner
already honor). On first create it is seeded from the developer's real
~/.omnigent/config.yaml so the pod works out of the box (keeps their
providers); thereafter the two are independent, so server-config edits
made while testing in a pod no longer leak into the real user config.
--clean wipes the pod dir, so the next run re-seeds.

Co-authored-by: Isaac
2026-07-10 09:05:42 +00:00
Pat Sukprasert d677bd98f1 Stabilize interrupt forward ordering test (#2352)
* ci(images): make the Docker build check a required merge gate

The build-only PR check added in #2288 has proven fast (~1m28s cache-cold)
and reliable, so promote it from report-only to a blocking merge gate.

- required.sh: add "Docker build" to REQUIRED, and to ALLOW_SKIP with a
  workflow_for() arm so a PR whose paths filter skips the build (nothing
  image-relevant changed) doesn't strand the gate — a missing check is
  treated green only when its workflow legitimately didn't run.
- merge-ready.yml: add "Docker build" to the workflow_run list so the gate
  re-evaluates when the build completes.

Safe for fork / non-maintainer PRs: the check builds with push:false (no
secrets, no registry) and already runs behind the security gate, so it
behaves identically to a maintainer PR.

Co-authored-by: Isaac

* Stabilize interrupt forward ordering test

Co-authored-by: omnigent <noreply@omnigent.ai>

---------

Co-authored-by: omnigent <noreply@omnigent.ai>
2026-07-10 15:51:40 +07:00
Jackson Zheng 60b9f40991 Omnigent embedded browser (#2248)
* feat(browser): embedded browser pane + design mode

Add a user-driven embedded Chromium browser as a right-rail Workspace tab
in the Electron desktop app: a native WebContentsView per conversation,
positioned over a measured placeholder, with a URL bar + back/forward/
reload/DevTools toolbar. Includes design-mode point-and-prompt — hover to
highlight an element, click to open an anchored input, Send routes the
element + a cropped screenshot to the agent through the normal chat path
(no backend route).

The renderer consumes the backend's `browser.action_request` SSE event by
string key and drives the view via a claim-first relay hook; the coupling
to the agent-tools half is this runtime event only — no compile-time
dependency, so this half builds and tests standalone.

Hardening: agent-issued navigation is gated by a scheme/host allowlist
(browserUrlPolicy.js — no file://, loopback, metadata, or private hosts);
design-mode submit markers require a real native input gesture within a
short window and carry a per-enable nonce, so a hostile page can't forge
unattended submits.

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* refactor(browser): extract design-mode picker script to its own module

Move the ~270-line design-mode picker driver (the in-page IIFE injected
via executeJavaScript) out of the inline template literal in browserIpc.js
into web/electron/src/designModeScript.js, so it lints and highlights as
its own file instead of an opaque backtick string.

Behavior is byte-identical: the function is moved verbatim, keeping its
(nonce) signature and internal SELECT/SUBMIT/DISMISS marker derivation, so
the produced script string matches the old one exactly for the same nonce
(verified by diffing the output across several nonces). browserIpc.js now
imports buildDesignModeScript and re-exports it, so the existing tests that
require it from browserIpc keep working unchanged. No security logic
touched — the per-enable nonce, gesture gate, and console-marker channel
are all preserved as-is.

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* docs(browser): tighten comments across the browser UI

Compress verbose multi-sentence comment blocks and JSDoc prose to terse
one-liners across the net-new browser UI files (normalizeTypedUrl,
browserActionBus, designModePrompt, browserUrlPolicy, BrowserPane,
useBrowserAgentRelay, browserViewBounds, railTabs). For the large shared
files (events.ts, sse.ts, chatStore.ts, AppShell.tsx, WorkspacePanel.tsx)
only OUR added comments were trimmed — every pre-existing upstream comment
is byte-identical.

Comments/docstrings only — no logic, identifier, JSX, or string changes;
JSDoc @param/@returns type tags preserved (tsc still parses). Load-bearing
WHYs kept as one-liners: the nav-allowlist SSRF rationale, the design-mode
gesture/nonce security note, the claim-first Risk-1 note, the rAF/layout
traps in BrowserPane.

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* docs(browser): drop internal review-tracker references from comments

Remove internal security-review severity labels (P0/P1/P1-1/P1-2, "P1 fix")
and private design-doc citations (Risk-1/Risk-2/Risk-4) from browser-UI
comments, docstrings, the electron README, and test describe() names —
they're meaningless/leaky to a public reader. The security invariants
themselves are kept (nonce gating, isPinnedOriginSender gate, agent-nav
allowlist, execute trust boundary, single-winner claim) — only the
internal citation is dropped. Comments/test-names only; no logic change.

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* docs(electron): fix browser-pane README terminology + split framing

Two accuracy fixes in the embedded-browser section:
- the browser_* tools are framework-owned BUILTIN agent tools, not MCP
  tools — drop the "MCP" wording.
- post-split this README ships in the UI PR (the pane + toolbar + design
  mode + renderer plumbing); frame the agent-facing browser_* tools as
  landing in a separate PR, and the relay as receiving action requests
  from it. Docs-only.

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* docs(browser): drop redundant SECURITY labels from comments

The SECURITY: prefix was on 7 Electron comments; most just narrate normal
behavior. Drop it from the 5 narration ones (keeping the sentence) and keep
it on the 2 genuine do-not-regress invariants: the preload's deliberate
omission of a generic agent evaluate, and the console.log main-world
back-channel note the nonce gate depends on. Comments-only.

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* docs(browser): drop internal phase reference from comments

Remove the internal "Phase 2" plan reference from 3 spots we added (README
heading, main.js browserRegistry docstring, ChatPage.tsx comment) — it cites
a private phased plan, meaningless on a public repo. Also reword the
normalizeTypedUrl header + the README URL-bar note to use neutral examples
(localhost) instead of internal intranet shortnames (go/ , jira/). Keeps the
technical point (dotless host → http, host-with-dots → https); comments/docs
only, code already generic.

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* test(browser): use neutral hostnames in URL-normalization tests

Replace internal-convention fixtures (go/, glean, jira/PROJ) and the
"(corp shortname)" test name with neutral dotless hosts (myhost, wiki/…)
that exercise the same behavior. Assertions unchanged in intent — dotless →
http://, dotted → https://, explicit scheme preserved; test count stays 5.

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* fix(deps): use public npm registry URLs in lockfile

The lockfile's resolved URLs pointed at an internal npm proxy
(npm-proxy.cloud.databricks.com), recorded when the lockfile was
reconciled after an upstream merge. That both leaks internal infra on a
public repo AND breaks npm ci for external contributors, who can't reach
the proxy. Swap all 137 resolved URLs to registry.npmjs.org; the
content-based sha512 integrity hashes are unchanged and still verify
(npm ci --dry-run: up to date, no integrity errors). Resolved-URL host
swap only — no version, integrity, or dependency-tree change.

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* docs(browser): rename AP->server in comments (use codebase terminology)

"AP" was internal design-doc vocabulary; Omnigent's own terms are
server/runner/host. Rename the 6 relay-hook comment/JSDoc references to
"server". Comments only; identical meaning.

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* docs(browser): add architecture diagram to the browser-pane README

Add a Mermaid sequence diagram to the embedded-browser-pane section
showing the action flow (agent → server → renderer/pane → local
WebContentsView → back), plus a one-line prose summary. Kept UI-PR-honest:
the diagram notes the browser_* tools ship in a separate PR and labels the
renderer/pane as "(this PR)". Docs-only.

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* test(browser): add e2e_ui coverage for the browser pane tab

Add tests/e2e_ui/browser/test_browser_tab.py covering the desktop-only
embedded-browser rail tab, to satisfy the E2E UI Required gate on the UI PR.

The pane is gated on isElectronShell(); the e2e_ui harness runs plain
Chromium, so — following the sessions/test_pinned_session_hotkeys.py and
mobile/test_android_shell.py precedent — the test injects a minimal
window.omnigentDesktop electron stub via add_init_script before navigation.
Two cases: (1) under the stub the "Browser" tab appears in the Workspace
rail, is the LAST tab, and selecting it mounts the pane (aria-selected);
(2) in a plain browser (no stub) the tab is absent while Agents renders.

DOM-based assertions, no LLM turn; runs against the harness's mock-LLM
server. Verified locally: 2 passed.

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* fix(browser): prettier formatting + lockfile sync

Two CI-gate fixes, no logic changes:
- Prettier: reformat the 10 browser files that drifted from prettier
  style (whitespace/wrapping only; jargon scrubs preserved). `npm run
  format:check` now clean.
- Lockfile: regenerate web/package-lock.json exactly as the lint.yml gate
  does (`npm install --package-lock-only --legacy-peer-deps`), which
  prunes the extraneous peer-pulled entries the check flagged. Idempotent
  (2nd regen = no diff); npm ci --legacy-peer-deps consistent. Kept the
  registry public (0 databricks-proxy hosts).

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* test(browser): raise UI coverage for browser-pane modules

Add honest unit coverage for the under-tested browser modules that were
dragging aggregate UI coverage down:
- useBrowserAgentRelay.ts: 5.55% -> 97.22% — claim-first protocol (win /
  lose / not-ok / throw), the full action-dispatch switch (navigate /
  screenshot / snapshot / click-by-ref+selector / type), arg marshaling,
  error + timeout branches, and result-POST resilience.
- browserActionBus.ts: 12.5% -> 100% — subscribe / emit / unsubscribe /
  dedupe / throwing-listener isolation.
- BrowserPane.tsx: extend the existing RTL test with toolbar handlers
  (reload / devtools / nav-state enable / url-bar reflect / dotless
  navigate).
- WorkspacePanel.tsx: cover the Browser tab render + pane-mount branch.

Tests only; no source change. Aggregate UI line coverage 79.97% -> 80.59%.
(Still ~0.04% under the 80.63% baseline — see PR discussion re: baseline.)

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* fix(browser): enforce agent-nav allowlist on redirects + deny child window.open (SSRF hardening)

B1 (blocking SSRF bypass): the agent-navigation allowlist was checked once,
before the initial loadURL. A server 302 / meta-refresh / location.href during
an agent nav then redirected the child view to an internal host (metadata /
loopback / RFC-1918) with no re-check, and browser_screenshot could exfiltrate
it. Wire will-navigate / will-redirect / will-frame-navigate on the child view
and preventDefault() any disallowed target, emitting a browser-nav-blocked
signal. Enforced only while the view is agent-locked (a per-entry flag set from
opts.agent on each navigation), so user-typed URL-bar browsing — including
legitimate auth-redirect chains to internal hosts — stays permissive.

S3: the child WebContentsView had no window-open handler, so a visited page
could spawn shell windows. Deny every window.open on the child view (safe
default; not routed to shell.openExternal — an agent page popping the user's
real browser is itself an abuse vector).

Tests: will-redirect/will-navigate to metadata/loopback/RFC-1918 on an
agent-locked view is preventDefault'd + signals blocked; a normal https→https
redirect is allowed; user-driven (non-agent) nav is NOT gated; a later user nav
unlocks a previously agent-locked view; the window-open handler denies popups.

Fast-follows noted, not in scope: S1 (DNS-rebinding, needs socket-level),
S2 (IPv6 fc00::/7 + IPv4-mapped hex holes in isBlockedHostname).

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

---------

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
2026-07-10 08:30:30 +00:00
Pat Sukprasert 4ba52571ae fix(harness-bench): stop the live --rich table flickering / cursor jumps (#2351)
The rich.Live progress table flickered and made the cursor jump around during a
run. Three causes, all fixed:

- refresh_per_second lowered 8 -> 4: fewer full repaints of a growing table.
- vertical_overflow="visible": a grid taller than the viewport now prints in
  full instead of rich clipping + repositioning it each frame (the cursor-jump
  thrash).
- whole-harness skip reason no longer appended to the row label: a long reason
  (up to 60 chars) + transport tag could wrap the Harness cell, changing row
  height mid-run and forcing a reflow. Rows are now always one line high. The
  reason is unaffected in output — it still prints in the stdout Notes section
  after the run (sourced from the matrix, not this sink).

Removes the now-dead self._notes state. Bench suite green; ruff clean.

Co-authored-by: Isaac
2026-07-10 16:27:57 +08:00
Pat Sukprasert fbf2f655f0 feat(harness-bench): add policy_allow + policy_ask probes (#2313)
* feat(harness-bench): add policy_allow + policy_ask probes

Extends the policy axis beyond DENY toward Tomu's ALLOW/DENY/ASK matrix. The
DENY probe proved a policy can block a call; these prove the other two verdicts:

- policy_allow: an explicit action=allow tool_call policy lets the call proceed
  (tool_call_allowed set from a non-blocked function_call_output).
- policy_ask: an action=ask policy parks the call on an elicitation
  (response.elicitation_request), which the driver resolves with an approval
  accept event so the turn settles instead of parking for the day-long ASK
  timeout. elicitation_requested is the observed signal.

Mechanism (full-server, the transport where policy is observable): generalize
the spec-baked deny into a fixed-action policy — _build_bench_agent_config /
register_agent take policy_action ("allow"/"deny"/"ask"); the driver caches one
session per action (_ensure_policy_session) and adds policy_probe_turn /
run_policy_turn. _scan_tool_items now also sets tool_call_allowed.

Honest SKIP elsewhere (per the coverage decision): sdk-inproc (wrap-only, no
policy surface) and native-tui (CEL ALLOW/ASK attach is a follow-up) return an
unmeasured result, so the probes SKIP rather than assert a false verdict. Native
Policy DENY stays covered by run_tool_turn(deny=True). MCP-vs-native tool
distinction is the next PR (PR-B3).

Both probes are P1 and undeclared in the manifest (like cost_tracking): no
capability axis, verdict varies by transport, so declaring SUPPORTED would
manufacture false DRIFT. TurnResult gains elicitation_requested /
tool_call_allowed.

New test_policy_matrix.py (network-free) covers both probes' verdict branches.
Full bench suite 98 passed / 18 skipped; ruff clean; no uv.lock drift. Lands in
tests/harness_bench/ (not the parked package-move location).

Co-authored-by: Isaac

* docs(harness-bench): document Policy ALLOW / ASK

Add the two new policy verdicts to the README alongside Policy DENY: the
plain-terms table (ALLOW = the call actually goes through, not just
"wasn't blocked"; ASK = the call pauses for an approval prompt / elicitation),
the per-transport "what a ✓ verifies" table (full-server spec-baked allow/ask;
`·` on native-tui and sdk-inproc, where the attach is a follow-up), and Scope
(live on full-server; native ALLOW/ASK + MCP-vs-native distinction noted as
open items). Also updates the "what a ✓ means" narrative so the transport-`·`
cells include ALLOW/ASK, not just DENY-under-`--fast`.

Docs only.

Co-authored-by: Isaac

* refactor(harness-bench): address review notes on policy probes

Review feedback (Polly + code-quality bot):
- Document the two best-effort except blocks in policy_probe_turn's watcher
  (code-quality: empty-except) — note when an unparseable elicitation id means
  the turn parks to the deadline, and that an SSE read error must not fail it.
- Tighten the tool_call_allowed docstring: it's set for any non-blocked tool
  output, not only under ALLOW; the probe's correctness comes from driving a
  real action=allow session.
- Extend the manifest UNKNOWN-not-declared note to cover policy_allow/policy_ask
  alongside cost_tracking.
- Trim verbose comments/docstrings per request (probes ~69->56 lines).

Stacking note from the review is already resolved: rebased onto main after
#2307 landed, so the cost feature reconciles to zero-diff here. Subscription-
race (time.sleep before ASK subscribe) left as a documented P1 live-flake.

100 passed / 18 skipped; ruff clean.

Co-authored-by: Isaac

* perf(harness-bench): policy_ask returns as soon as the elicitation fires

The ASK verdict is decided the moment response.elicitation_request arrives, but
the loop kept polling the turn to a terminal state — so a run where the model
never called the tool (no elicitation) burned the full 180s timeout before
SKIPping. Now: once elicitation_requested is set, resolve the elicitation (so no
park dangles) and break immediately. Also lower the timeout 180s -> 90s, so the
worst case (no tool call) is a bounded SKIP, not a 3-minute stall.

A real ASK success now returns with elicitation_requested=True but
completed=False (we don't wait for the turn to settle); added a unit test
locking that verdict shape.

Co-authored-by: Isaac

* fix(harness-bench): nest elicitation_id in data so the ASK resolve lands

Polly caught a real defect: _resolve_elicitation posted the approval event with
elicitation_id at the TOP LEVEL, but POST /v1/sessions/{id}/events deserializes
into SessionEventInput (no top-level elicitation_id field) and the handler reads
data.get("elicitation_id"). So the id was dropped, no Future matched, and the
resolve was a silent no-op — the parked ASK elicitation dangled until server
teardown.

Fix: send the canonical shape {"type":"approval","data":{"elicitation_id":...,
"action":"accept"}} (matches test_sessions_endpoints.py:4960). The ASK verdict
was already correct (decided when response.elicitation_request fires); this makes
the method actually settle the parked turn as intended.

Added a network-free test asserting the id is nested in data (guards the payload
shape a fake-client can verify without a live server).

102 passed / 18 skipped; ruff clean.

Co-authored-by: Isaac

* refactor(harness-bench): key ASK watcher on parsed event type, not substring

Per Polly's non-blocking note: the SSE watcher matched on the substring
'"response.elicitation_request"' in the raw frame, so an unrelated frame merely
mentioning that string (e.g. a mirrored/resolved event) could set the ASK
verdict early. Parse the frame once with json.loads and key on
frame.get("type") == "response.elicitation_request" instead — more robust, and
the parse was already happening right after to read the id.

102 passed / 18 skipped; ruff clean.

Co-authored-by: Isaac
2026-07-10 16:26:09 +08:00
Pat Sukprasert bc140bc5c0 docs(readme): point to the harness test bench (#2349)
* docs(readme): point to the harness test bench

The harness test bench (tests/harness_bench/) has no pointer from the
root README, so contributors adding or changing harness support can
easily miss it. Link to it from the Contributing section alongside
the design doc.

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>

* Apply suggestion from @PattaraS

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-10 15:00:05 +07:00
Edwin He 76bb9002d9 Route out-of-process native posters through databricks_request_headers (#2328)
The pi JS extension and the opencode policy plugin run OUT of the runner
process and POST to the omnigent server with a hand-rolled `Authorization:
Bearer` header, bypassing databricks_request_headers -- the single chokepoint
that folds in the server-routing selectors (X-Databricks-Org-Id and the opaque
OMNIGENT_DATABRICKS_EXTRA_HEADERS map that some Databricks deployments use to pin
a request to a specific server instance). Without those selectors their POSTs can
land on a different server instance than the one the runner and the web UI are
bound to, so on a multi-instance deployment pi's streamed items never reach the
browser's in-process event stream (they only appear on reload) and opencode's
policy evaluation hits a different instance.

- cli_auth: fold OMNIGENT_DATABRICKS_EXTRA_HEADERS into
  databricks_request_headers (opaque JSON header map; no-op when unset).
- pi: build the extension config.authHeaders (launch + per-turn refresh) via
  databricks_request_headers.
- opencode: bake the full routing header map as OMNIGENT_POLICY_HEADERS and merge
  it in the policy plugin, replacing the bearer-only OMNIGENT_POLICY_AUTH.
- host: allowlist OMNIGENT_DATABRICKS_EXTRA_HEADERS in the host->runner env
  builder so a host forwards the routing selectors to the runners it spawns.
  Without it the host tunnel lands on the selected instance while its runners
  fall back to the default one (their tunnel + callbacks register elsewhere), so
  the session's runner is unreachable from the instance serving the UI and the
  session reports runner_failed_to_start.

In-runner Python clients already route via _RunnerDatabricksAuth / _remote_headers;
the gaps were the two out-of-process posters and the host->runner env handoff.

Co-authored-by: Isaac

Signed-off-by: Edwinhe03 <41037314+Edwinhe03@users.noreply.github.com>
2026-07-10 00:32:37 -07:00
Chanhyo Jung a75b64a4b3 fix(claude-native): mirror launch overrides into settings (#2116) 2026-07-10 07:12:17 +00:00
Pat Sukprasert 46e3cd9754 feat(harness-bench): add cost_tracking probe (#2307)
* feat(harness-bench): add cost_tracking probe

Cost tracking is the keystone for cost policies (Tomu): a cost_budget guardrail
is a no-op without usage to measure. This adds a P1 cost_tracking probe that
answers "can the operator see what a turn spent?".

- TurnResult gains total_tokens / total_cost_usd (both Optional; None = the
  transport surfaced no usage).
- fill_snapshot_cost(result, snapshot) in driver.py reads the cumulative
  totals the server records on the session snapshot (SessionResponse
  total_cost_usd / last_total_tokens) — the uniform read point both
  server-backed drivers already poll. full-server fills it on turn completion;
  native-tui reads the snapshot post-turn (its usage arrives via
  external_session_usage -> session.usage). sdk-inproc (wrap-only, no server)
  fills from the completed turn's embedded usage when the wrap forwards it,
  else leaves it None.
- Probe verdicts: SUPPORTED (priced cost), PARTIAL (tokens but no price =
  unpriced model — usage visible, USD-cost policy can't price it), SKIPPED
  (no usage surfaced / infra failure / timeout). Never a false UNSUPPORTED.
- Deliberately NOT declared in the manifest (left UNKNOWN): no backing
  capability axis, and the observed verdict legitimately varies, so declaring
  SUPPORTED would manufacture false DRIFT against a legitimate PARTIAL. The
  P0-coverage test only requires declared verdicts for P0 dims, so a P1
  probe with no declaration is allowed.

New test_cost_tracking.py (network-free) covers the verdict logic +
fill_snapshot_cost. Full bench suite 89 passed / 18 skipped; ruff clean; no
uv.lock drift. Lands in tests/harness_bench/ (not the parked package-move
location).

Co-authored-by: Isaac

* fix(harness-bench): cost probe requires positive usage, not just non-None

A completed turn always spends tokens, so a reported total_cost_usd == 0 or
total_tokens == 0 means the usage plumbing returned an empty default, not that
tracking genuinely measured zero. The `is not None` check would render a $0.00
turn as SUPPORTED — a false pass. Require a POSITIVE value:

- cost > 0 -> SUPPORTED
- tokens > 0 (cost None/0) -> PARTIAL (unpriced)
- both absent or zero -> SKIPPED

Readers (fill_snapshot_cost, sdk-inproc) still carry whatever the server
reported (including 0, distinct from absent); the >0 judgment lives in the probe
where interpretation belongs. Added tests for the 0/0 -> SKIP and
0-cost/positive-tokens -> PARTIAL cases.

Co-authored-by: Isaac

* docs(harness-bench): document cost_tracking; drop P0/P1 jargon

Add the Cost tracking dimension to the README: the plain-terms table (✓ priced
cost / ~ tokens-only / · no usage, and that it gates any cost policy), the
per-transport "what a ✓ verifies" table (snapshot read on server transports;
wrap-usage on sdk-inproc else ·), and the Scope section (now live).

Drop the P0/P1 framing from the public-facing doc — it's internal
(merge-gating vs reported) and doesn't help a reader. The Priority field stays
in code; the README just describes the dimensions.

Also corrects a stale Scope claim: native Tool calling / Policy DENY are
observed now (landed separately), not "not yet wired".

Docs only.

Co-authored-by: Isaac
2026-07-10 14:36:20 +08:00
amruthkesav 55764b6da4 fix(electron): reload desktop window when workspace SSO session expires (#1997)
* fix(electron): reload desktop window when workspace SSO session expires

A workspace-hosted Omnigent sits behind the Databricks SSO gate. When
that outer session's cookie lapses, the gate answers the SPA's API calls
with a 303 redirect to its own login.html instead of the expected JSON.
The SPA can't parse the login page as data and dies on a "Failed to
load: Fetch request failed due to expired user session" panel — and a
desktop user has no address bar to force a refresh out of it.

An earlier attempt handled this in the web SPA (identity.ts), but that
can't work here: the desktop app loads whatever bundle the remote server
serves, so an un-deployed SPA change never runs, and the host fetcher
rejects before any status/content-type check the SPA could inspect.

Handle it in the Electron shell instead. The shell sees the raw redirect
via session.webRequest.onBeforeRedirect regardless of which server bundle
is loaded, so it detects a 3xx redirect to login.html for a connected
server origin and reloads the affected windows. The reload re-issues the
top-level navigation the SSO gate inspects, so it can re-challenge and
re-mint the session. A per-window minimum interval caps reloads so a
persistently expired host can't reload-loop.

The detection logic lives in an Electron-free module (session-expiry.js)
so isLoginRedirect and the onBeforeRedirect wiring are unit-testable via
node --test without booting the app.

Co-authored-by: Isaac

* fix(electron): skip destroyed windows in the session-expiry reload loop

The reload loop in registerSessionExpiryAccess called win.webContents.reload()
without checking win.isDestroyed(). A BrowserWindow handle can outlive its
native window (the windows map keeps it reachable until the "closed" handler
removes it), so in the race between native destroy and map removal a
login-redirect callback could call reload() on a dead handle — which throws out
of the onBeforeRedirect listener and skips the remaining windows.

Fold the isDestroyed() check into the existing continue-guard, matching the
idiom used elsewhere in this file when iterating the windows map.

Co-authored-by: Isaac

---------

Co-authored-by: Amruth Sampath <amruth.sampath@databricks.com>
2026-07-10 08:09:17 +02:00
Yuan Tang 5b04596a08 feat(web): add graph view for subagent tree in Agents panel (#1201)
* feat(web): add graph view for subagent tree in Agents panel

* test(ui-snapshot): update visual baselines
2026-07-10 05:48:23 +00:00
Enes Yilmaz e89d6a0c8e fix(web_fetch): probe for bwrap at researcher-spec build time (#2097)
* fix(web_fetch): probe for bwrap at researcher-spec build time

A parent with no os_env hands the __web_researcher sandbox=None, which
resolve_sandbox fills with the platform default (linux_bwrap on Linux)
without checking the binary exists. The spawn then failed mid-run and
the error told the user to set os_env.sandbox.type, which a spawn-only
parent cannot apply without also registering OS tools on itself.

Probe shutil.which("bwrap") in build_researcher_spec for the no-os_env
case and fail at spec-build time with the remediation the operator can
actually use: install bubblewrap on the host. Parents that declare
their own os_env keep the inherit-verbatim path untouched.

Fixes #2068

Signed-off-by: Enes Yilmaz <115046343+EnesYilmazcode@users.noreply.github.com>

* fix(web_fetch): extend the seed-time sandbox probe to macOS

Review follow-up on #2097: darwin_seatbelt needs sandbox-exec on PATH,
mirroring the fail-loud check in SeatbeltSandboxBackend.resolve. The
Windows default windows_jobobject drives kernel Job Objects through
ctypes with no external binary, so there is nothing to probe there;
documented in the docstring.

Signed-off-by: Enes Yilmaz <115046343+EnesYilmazcode@users.noreply.github.com>

* test(web_fetch): keep seed-time sandbox probe host-independent

The new _ensure_default_sandbox_runnable() probe calls shutil.which
against the real host PATH for a no-os_env parent, so every existing
test that builds a researcher spec from such a parent now raises
OmnigentError on any runner without bubblewrap / sandbox-exec
installed (the unit-test CI job). Add an autouse fixture defaulting the
probe to "binary present"; the probe-specific tests override it with
their own monkeypatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnpHpxeDkqfkrUEt3Sc3sj

---------

Signed-off-by: Enes Yilmaz <115046343+EnesYilmazcode@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-10 14:00:04 +09:00
Tomu Hirata ac9e49cc31 fix(smart-routing): enforce rationale consistency with selected model tier (#2339)
* fix(smart-routing): enforce rationale consistency with selected model tier

Restructures the judge prompt to require explicit SIMPLE/MODERATE/COMPLEX
task classification, each mapped to a concrete model tier (haiku/sonnet/opus,
nano/mini/base), and enforces a structured rationale format so the explanation
always matches the chosen model.

* fix(smart-routing): restore Trade-off guidance label
2026-07-10 13:09:15 +09:00
Zeyi (Rice) Fan 5b91f425eb fix(electron): repair the Electron Build workflow (#2337)
* fix(electron): resolve lockfile from public npm registry

web/electron/package-lock.json pinned 286 of its 290 resolved URLs to the
internal npm-proxy.cloud.databricks.com mirror, which is unreachable from
public GitHub runners. npm ci fetches each tarball from its exact resolved
URL, so the Electron Build workflow stalled for ~8 minutes on the first fetch
and died with "Exit handler never called!" on both Linux and Windows.

Rewrite those URLs to registry.npmjs.org, matching web/package-lock.json
(already all-public) and the uv.lock normalization. The integrity hashes are
content-based and unchanged, so they still validate against the public
tarballs.

Co-authored-by: Isaac

* fix(electron): add publish provider and repository so build completes

After packaging the AppImage/deb/nsis artifacts, electron-builder 26.x crashed
in computeChannelNames with "Cannot read properties of null (reading 'channel')"
because it computes auto-update channel metadata but found no publish provider
and could not detect the repository (repeated "Cannot detect repository by
.git/config" warnings).

Add a github publish provider and a top-level repository field. Under
--publish never the metadata is generated locally without uploading, so the
build no longer throws.

Co-authored-by: Isaac
2026-07-10 02:35:09 +00:00
Andrew Li 7fb779fdef fix(codex-native): surface MCP startup in the web session and let Stop cancel it (#2128) 2026-07-09 19:26:31 -07:00
Tomu Hirata 86e6abdbbe fix(policy-hook): surface error details in UI and treat 403 as re-auth signal (#2334)
* fix(policy-hook): improve reauth logging and proactively refresh lapsed bearer

The baked one-shot hook token was silently failing: all exceptions in
_reauth() were swallowed with no stderr, making it impossible to tell
whether the factory import failed, no credential was available, or the
mint itself threw. Add distinct log lines for each failure path.

Proactively re-mint the bearer before the first evaluate POST when the
JWT exp claim shows the token is within 5 min of expiry (or already
lapsed). Handles the "runner older than ~1h" case without waiting for a
401/302 — the one-shot reauth fires before the request rather than as
a recovery.

* fix(policy-hook): drop proactive reauth — only improve failure logging

Proactive JWT expiry check was not fixing the actual failure pattern:
when reauth() returns None (the bug case), proactive fires first,
gets None, and the session still fails closed — same outcome as before.
Remove it.

Keep only the logging improvements: each _reauth() failure path now
prints a distinct stderr message instead of silently returning None.

* fix(policy-hook): treat 403 as re-auth signal alongside 401 and 302

Databricks Apps returns 403 "Invalid Token" for an expired bearer, not
401. Both _is_login_redirect_or_unauthorized implementations only
checked 401 and 302→/oidc/, so the 403 fell through as a final
non-retryable 4xx — the reauth callable was never invoked and the hook
failed closed on every call for sessions older than ~1h.

Extend both the hook and runner functions to treat status 401 and 403
as re-auth signals. Add a parametrize case for 403 in the classifier
test and an integration test that a 403 response triggers reauth and
retries with the fresh token.

* test(policy-hook): harness-level regression test for 403 reauth

Mirrors test_evaluate_policy_reauths_on_expired_token_instead_of_failing_closed
but with a 403 "Invalid Token" response instead of 302→/oidc/. Drives the
full claude_native_hook.main() → bridge dir → httpx → PolicyHookReauth →
retry path, asserting two attempts (stale token, then fresh) and that the
routing header survives the re-mint.
2026-07-10 01:40:35 +00:00
Tomu Hirata 7afc6433b2 fix(policies): apply DB-stored default policies to every session evaluation (#2333)
* fix(policies): apply DB-stored default policies to every session evaluation

PolicyStore.list_defaults() (policies created via POST /v1/policies with
session_id=NULL) was never consulted during engine construction — only
YAML-based caps.default_policies were included in admin_policy_specs.
Added _load_default_policy_specs() and call it in build_policy_engine so
DB-stored defaults are fetched fresh on every evaluation, inserted between
agent-spec policies and the YAML admin policies.

* feat(policies): cache DB default policy specs; add tests

- Add _DEFAULT_POLICY_SPECS_CACHE (TTLCache, 30 s, keyed by workspace_id)
  in builder.py so list_defaults() is only called once per 30-second
  window per workspace instead of on every tool-call evaluation.
- Add invalidate_default_policy_specs_cache() and call it in the
  create/update/delete default policy routes so changes propagate
  immediately rather than waiting for the TTL to expire.
- Add tests: _load_default_policy_specs (none store, filters disabled,
  cache hit, invalidation), build_policy_engine DB-default inclusion,
  and the full four-layer ordering (session → agent → DB default → YAML admin).

* fix(policies): guard against url-type default policies bricking all sessions

A single enabled url-type default policy would raise OmnigentError in
_load_default_policy_specs on every build_policy_engine call, taking
down session construction server-wide. Two-pronged fix:

- Reject type='url' at create_default route: default policies now only
  accept type='python' (same restriction as session policies, but
  enforced at API time so the bad state can't be persisted).
- Skip-with-warning in _load_default_policy_specs for any unsupported
  type: a stale or manually-inserted row is logged and skipped rather
  than raising, limiting blast radius to a warning log entry.

Adds test asserting the skip-with-warning path (url row skipped, python
row still included).

* test(policies): fix default policy route tests to use type='python'

The create_default route now rejects type!='python'. Update tests to use
a registered python handler, add test_create_url_policy_rejected to
assert the 400, and remove the stale url-type payload from _policy_payload.

* feat(policies): cache session policy specs with invalidation on mutation

Add _SESSION_POLICY_SPECS_CACHE (plain dict, no TTL) keyed by
(workspace_id, conversation_id). Unlike default policies (TTL cache),
session policies must be visible immediately after sys_add_policy, so
invalidation-on-mutation is used instead of TTL.

invalidate_session_policy_specs_cache() is called after create, update,
and delete in the session policies route. Tests cover cache hit and
invalidation behavior.

* test(policies): fix oidc default policy test to use type='python'

* fix(policies): bound session policy cache (LRU) and remove dead branch

- Switch _SESSION_POLICY_SPECS_CACHE from unbounded dict to
  LRUCache(maxsize=4096), matching _SESSION_OWNER_CACHE and preventing
  unbounded memory growth on long-lived servers.
- Remove the dead `if body.type == "python":` branch in create_default
  (unreachable after the preceding `if body.type != "python": raise`).
2026-07-10 10:28:14 +09:00
Matt Adams eed3845851 fix(host): re-exec via login shell to inherit full PATH on GUI launch (#1935)
* fix(host): re-exec via login shell to inherit full PATH on GUI launch

GUI-launched Electron inherits a minimal PATH from the desktop launcher
(launchd on macOS, systemd on Linux) that omits Homebrew, nvm, pyenv and
other user-installed tool directories. This meant claude, codex, tmux and
similar tools were missing when spawned from the Omnigent desktop app.

Extract loginShellPath.js to resolve the full login-shell PATH by spawning
`$SHELL -l -c 'echo $PATH'` and patch process.env.PATH at Electron startup.

Add Playwright browser-flow tests for the resolver's pure resolution logic
(trim, null-on-failure, colon-separated output) via dependency injection.

* fix(host): harden login-shell PATH resolution (-ilc, delimiter, merge, real test)

The login-shell PATH resolver worked for the simple case but missed the
edge cases that hit exactly the GUI-launch users #1933 targets:

- Use `-ilc` (interactive+login) instead of `-l`. A login-only shell sources
  the profile but NOT the rc file (.zshrc/.bashrc), where nvm/pyenv and most
  hand-rolled PATH exports live — so `-l` alone still missed those tools.
- Source the shell from the passwd DB (os.userInfo().shell), then $SHELL, then
  a POSIX fallback list. $SHELL is typically unset in a GUI launch (the premise
  of this bug), so relying on it fell back to /bin/bash for zsh users.
- Bracket $PATH in delimiter markers and strip ANSI before parsing, so an
  rc-file banner / MOTD / version-manager greeting can't corrupt the result.
- Suppress hang-prone startup hooks (oh-my-zsh auto-update, zsh tmux plugin,
  pagers) in the child env so a heavy rc file doesn't trip the timeout.
- Recover a delimited PATH from err.stdout when a shell exits non-zero after
  already printing it.
- Add a fast-path skip when PATH already looks complete (launched from a
  terminal), and merge (union, dedup) rather than replace process.env.PATH —
  matching what the main.js comment already claimed.

Tests: replace the Playwright/Python test (which exercised a reimplementation
of the resolver in a browser, not the shipping module) with a node --test suite
that requires the real loginShellPath.js and injects execFileSync/os/env/platform
mocks, plus a source-guard pinning the main.js merge wiring. Full electron
suite: 76 pass.

Co-authored-by: Isaac

* style(host): prettier-format loginShellPath test

Collapse a chained .replace() onto one line to satisfy the repo's prettier
config (printWidth 100), matching the web-prettier pre-commit hook.

Co-authored-by: Isaac

---------

Co-authored-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-09 17:33:54 -07:00
xtra 6d55390440 fix parser numeric bool coercion (#1069)
Co-authored-by: wxrth <191876097+wxrth@users.noreply.github.com>
Co-authored-by: Sabhya Chhabria <sabhyachhabria@gmail.com>
2026-07-09 23:08:09 +00:00
ikatyal2110 335cab5475 fix(databricks): error on truncated stream with no finish_reason and no content (#1189)
A gateway stream that ends without a finish_reason, no content, and no tool
calls means the worker turn died mid-stream. The executor yielded a silent
empty TurnComplete, so an aborted turn was sometimes accepted as a clean
completion and sometimes surfaced elsewhere as a reasonless failure. Emit an
ExecutorError with a clear message instead; a truncated stream that did
produce text still completes (with a warning).

Fixes #1118

Co-authored-by: ikatyal21 <ikatyal@terpmail.umd.edu>
Co-authored-by: Sabhya Chhabria <sabhyachhabria@gmail.com>
2026-07-09 15:48:02 -07:00
dosenr 65264eedf3 fix(server): resolve endpoint never wakes a parked harness elicitation (#2142)
Resolving an elicitation through the resolve endpoint completes the
elicitation Future but never signals resolved_elsewhere, so a harness
turn parked on that elicitation stays parked until its timeout. Visible
symptom: approving an inbox card returns 202 and the approved tool call
never resumes.

Wire the resolve path to the existing resolved_elsewhere registry, the
same mechanism the terminal resolve path already uses. The new test
parks a harness elicitation, resolves it via the endpoint, and asserts
the parked wait wakes with the verdict; it fails before the fix.

Signed-off-by: Robert Dosen <robert.dosen@gmail.com>
2026-07-09 13:25:33 -07:00
Dhruv Gupta 2eba6bc3b8 fix(web): prevent editor crash on list items with a block-first child (#2320)
A markdown file whose list has an item starting with a non-paragraph block
— a nested list (`- - x`), a fenced code block, a blockquote, a heading, or
a table — crashed the markdown editor's panel.

@tiptap/markdown (beta) parses those into a `listItem` whose first child is
that block, which violates the stock `paragraph block*` content model.
ProseMirror builds the initial document via `nodeFromJSON`, which does not
validate content, so the invalid doc loads silently — then the first
transaction that touches the list item (a user edit, or StarterKit's
TrailingNode appendTransaction that runs on load) calls `contentMatchAt` on
it and throws ("Called contentMatchAt on a node with invalid content"). The
viewer's React panel boundary catches the throw and renders a crash instead
of the file.

Relax the list item's content model to `block+` (SafeListItem) so a
non-paragraph first child is schema-valid. Same crash family as the
blockquote fix in #2004, but for list items — which agent-authored markdown
hits constantly.

Co-authored-by: Isaac
2026-07-09 19:35:15 +00:00
ShiZai c49cd59692 fix(hermes): bound the idle turn count to the mirrored high-water mark (#2161)
A final assistant row that lands while a poll's batch is still being
POSTed was picked up by the fresh completed-turn count at the end of the
same iteration, ringing the parent-waking idle edge before the row
itself was mirrored — a sub-agent orchestrator woke to a transcript
missing the final answer. Count only rows at or below the mirror's
high-water mark so the completion signal can never overtake the content
it announces.

Co-authored-by: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:08:18 -07:00
Pat Sukprasert 5ad635a47d feat(harness-bench): derive creds like omni run; --profile optional (#2298)
* feat(harness-bench): derive creds like `omni run`; --profile now optional

The bench always minted its own bearer via a `databricks auth token` subprocess
(which does not handle OAuth `databricks-cli` profiles) and required --profile
for any live run -- a path entirely separate from how `omni run` authenticates.

Add tests/harness_bench/runtime_env.py with resolve_bench_env(), mirroring
`omni run`'s credential layering:

1. ambient OPENAI_BASE_URL + OPENAI_API_KEY win (skip resolution entirely, the
   same short-circuit `omni run` has),
2. else the profile from --profile, else the ~/.omnigent/config.yaml
   auth:/profile block (what `omni run` reads),
3. compose OPENAI_* via the canonical resolve_databricks_workspace()
   (OAuth-aware, fail-loud on a typo'd profile) -- the resolver the runner uses.

So a no-flag run now derives creds exactly like `omni run`, and --profile
overrides. bench_creds_skip_reason() gives every driver's unavailable() a cheap,
token-free gate: a run skips cleanly when no creds are resolvable instead of
requiring a flag.

- SharedFullServer takes a BenchRuntimeEnv (was db_profile: str); __enter__
  drops _mint_bearer + lookup_databricks_host and uses env.base_env.
- FullServerDriver / NativeTuiDriver / SdkInprocDriver resolve via
  resolve_bench_env; databricks_profile is now Optional throughout (the
  --profile override, None = derive). run_bench keeps the kwarg for back-compat.
- The full-server agent spec and the native provider-config omit
  executor.profile / the auth: block when auth came from the ambient env.
- __main__: a live run no longer requires --profile; it turns on whenever creds
  are resolvable, and --no-live forces the offline declared matrix.

This is deliberately independent of the package-move / `omni bench` work: it
stays in tests/harness_bench/ and is valid regardless of where the bench ends up
or what its user-facing entry point becomes.

Note: this drops the bench-only #1781 stale-token strip (env -u
DATABRICKS_TOKEN). Intentional -- `omni run` uses the same resolver and does not
strip either; aligning with omni is the point.

New test_runtime_env.py covers the layering (ambient wins, --profile overrides
config, config-derived, no-creds skip, hostless profile). 80 passed / 18
skipped; ruff clean; e2e still collects (376).

Co-authored-by: Isaac

* fix(harness-bench): resolve profile from providers: block, like omni run

The first cut of _profile_from_config only read the auth: block and a top-level
profile: key. But a machine configured through the provider wizard (rather than
`omni setup`) has neither -- its Databricks creds come from a
providers.databricks entry (default: true, profile: <name>). omni run resolves
that via default_provider_for_harness (runtime/workflow.py DATABRICKS_KIND
branch), so with no --profile it goes live; the bench went offline instead.

Add a third tier to _profile_from_config that reuses omni's own
default_provider_for_harness resolver (the same call resolve_credential and the
runtime spawn-env builder use) and reads .profile when it's a databricks
provider -- no reinvented selection logic, so the bench picks exactly the
profile a launch would. New test covers the providers:-block path.

81 passed / 18 skipped; ruff clean.

Co-authored-by: Isaac
2026-07-09 14:02:13 +00:00
Pat Sukprasert e3b2548b80 docs(harness-bench): explain what a ✓ means per transport (#2300)
A green cell is only as strong as the layer the probe drove it through, and that
differs by transport. Add a "What a ✓ actually means" section with a
per-dimension x per-transport table (full-server / native-tui / sdk-inproc)
spelling out exactly what each ✓ verifies, so a reader can tell whether a tick
implies end-to-end coverage for web-UI users.

Key points now written down instead of tribal:
- full-server (SDK default) and native-tui (native default) drive turns through
  the SAME server API the web UI uses (POST /v1/sessions/{id}/events + the
  /stream SSE), so a ✓ there is end-to-end through the server contract the
  browser depends on -- minus the browser render layer (that's tests/e2e_ui).
- sdk-inproc (--fast) drives the harness wrap directly, below the server; a ✓
  there does not imply the deployed server path works. Policy DENY is `·` there.

Also corrects two stale claims: native-tui now DOES observe Tool calling +
Policy DENY (landed in #2096/#2171), and sdk-inproc observes Tool calling (only
Policy DENY is missing there, not both).

Docs only.

Co-authored-by: Isaac
2026-07-09 13:38:03 +00:00
Pat Sukprasert 45da783590 ci(images): make the Docker build check a required merge gate (#2295)
The build-only PR check added in #2288 has proven fast (~1m28s cache-cold)
and reliable, so promote it from report-only to a blocking merge gate.

- required.sh: add "Docker build" to REQUIRED, and to ALLOW_SKIP with a
  workflow_for() arm so a PR whose paths filter skips the build (nothing
  image-relevant changed) doesn't strand the gate — a missing check is
  treated green only when its workflow legitimately didn't run.
- merge-ready.yml: add "Docker build" to the workflow_run list so the gate
  re-evaluates when the build completes.

Safe for fork / non-maintainer PRs: the check builds with push:false (no
secrets, no registry) and already runs behind the security gate, so it
behaves identically to a maintainer PR.

Co-authored-by: Isaac
2026-07-09 18:57:28 +08:00
Serena Ruan 936d65c141 fix(claude-native): emit JSON-parseable toolUseResult on cold resume (#2293)
Resuming a claude-native session from the web UI could crash the
`claude` CLI at boot with `JSON Parse error: Unrecognized token '<'`.
Its input prompt never rendered, so the readiness gate timed out after
30s and the first message was never delivered.

On cold resume the wrapper rewrites Claude's local transcript from
committed Omnigent items, unconditionally storing the tool result string
as `toolUseResult`. Claude Code's `TaskOutput` renderer `JSON.parse`s
that field at resume time, so a plain display string (e.g. an
`isaac review` result starting with `<retrieval_status>...`) threw at
startup. The tool result content block was fine — only `toolUseResult`
is parsed.

Add `_json_safe_tool_use_result`: outputs that are already JSON (e.g.
image content-block arrays) pass through verbatim; anything else is
wrapped as a JSON string literal so the parse always succeeds. The
verbatim string still lives in the tool_result content block, so what
the model and web UI see is unchanged.

Co-authored-by: Isaac
2026-07-09 18:47:08 +08:00
dosenr 255a5f8f10 fix(hermes): skip Omnigent relay tools in the pre_tool_call hook (#2220)
Omnigent relay tools surfaced into Hermes (mcp_omnigent_* / mcp__omnigent__*)
are already policy-gated when the relay dispatches them back through the
server's tool path. The pre_tool_call hook evaluated them a second time, parking
a duplicate approval card per call; a human resolves one and the other's
long-poll never returns, wedging the turn after the approved tool runs. Skip
those prefixes in the hook, matching the guard the native claude/codex hooks
already apply. Hermes' own tools (shell, file) and non-Omnigent MCP servers lack
the prefix and stay gated.

Signed-off-by: rdosen <robert.dosen@gmail.com>
2026-07-09 10:29:31 +00:00
Tomu Hirata a89fa733e2 feat(smart-routing): always route child sessions when parent toggle is on (#2291)
* feat(smart-routing): always route child sessions when parent toggle is on

Previously, smart routing was skipped for child sessions if the
orchestrator had already specified a model via sys_session_send (because
effective_runner_override was non-null). The routing verdict now always
wins over the LLM's own model choice when the parent toggle is on —
for both the SDK and native-terminal paths.

* fix: use conv.parent_conversation_id to detect child session in routing gate

* test: verify smart routing overrides orchestrator model for child sessions
2026-07-09 10:23:15 +00:00
Pat Sukprasert ea243f5f45 ci(images): publish nightly + release only, add PR build check (#2288)
Per-PR merges into main each triggered a full multi-arch image publish,
which is far more often than needed. Reduce the publish cadence and cover
the lost per-merge build validation with a build-only PR check.

- oss-publish-images.yml: drop the per-commit `push: branches: [main]`
  trigger (keep `tags: ['v*']`). The daily cron now rebuilds main HEAD and
  publishes :sha-<short> + :latest-nightly directly. Retire :latest-dev
  (redundant with the daily :latest-nightly once per-commit builds are gone)
  and the now-dead promote-nightly job + force_nightly dispatch input.
- docker-build.yml (new): on PRs touching image-relevant paths, build the
  server image single-arch (amd64) with the GHA layer cache and run a
  `omnigent --help` smoke, no push. Report-only for now; documented how to
  promote it to a blocking merge-gate check later.

Co-authored-by: Isaac
2026-07-09 17:52:04 +08:00
Arshdeep singh 777f75781c fix(goose): implement interrupt_session via ACP session/cancel (#1748) (#1807)
* fix(goose): implement interrupt_session via ACP session/cancel (#1748)

The web Stop button was a no-op for the goose harness because
GooseExecutor.interrupt_session fell through to the Executor no-op.

Fix: override interrupt_session in GooseExecutor to:
1. Send ACP `session/cancel` to request a clean stop (gives Goose a
   chance to close its own agent loop gracefully).
2. Fall back to SIGTERM on the subprocess when no session_id is
   established yet (e.g. the process is still initializing), mirroring
   the pattern used in KimiExecutor.

A dedicated `_interrupt_proc` helper (also used by the existing
asyncio.CancelledError path in run_turn) is added to avoid
duplicated terminate/suppress logic.

Tests added in tests/test_goose_executor_interrupt.py:
- interrupt with no live process → returns False
- interrupt before session established → terminates proc, returns True
- interrupt with live session → sends session/cancel RPC, returns True
- session/cancel error → falls back to SIGTERM, still returns True

* fix(goose): send session/cancel as an ACP notification

session/cancel is an ACP notification, not a request: the agent sends no
response and instead ends the in-flight session/prompt with a cancelled
stop reason. Dispatching it through _rpc() (which assigns an id and blocks
on a pending future) meant the graceful path always hit the timeout and
degraded to SIGTERM, adding latency to every Stop and never delivering the
clean partial-result cancel it was meant to.

Send it via _send() with no id, mirroring acp_executor.interrupt_session,
and let run_turn surface the cancelled stop reason. Drops the redundant
doubled asyncio.wait_for and the now-unused _CANCEL_TIMEOUT_SECONDS.

The interrupt test previously mocked _rpc to return a canned response goose
never sends, hiding the bug; it now asserts on _send and that the cancel
carries no id, exercising the real notification contract.

Co-authored-by: Isaac

---------

Co-authored-by: Daniel Lok <daniel.lok@databricks.com>
2026-07-09 17:24:52 +08:00
Yuan Tang 91a1897dc1 fix(web): surface server error message in stop-session dialog (#2252) 2026-07-09 05:24:40 -04:00
Tomu Hirata 5b41677443 ci: run store and db tests against PostgreSQL and MySQL (#2274)
* ci: run store and db tests against PostgreSQL and MySQL

Adds two new CI jobs (stores-postgres, stores-mysql) that exercise
tests/stores and tests/db against real service containers, using a
fresh per-test database created via OMNIGENT_TEST_DB_URI. Updates the
db_uri fixture to support non-SQLite backends, adds pymysql to the
databricks extra, and fixes three SQLite-specific tests (PRAGMA
foreign_keys, FTS5 queries) to skip on incompatible backends plus one
SqlConversationItem insertion that used raw strings instead of encoded
SMALLINT values.

* fix(ci): MySQL PK fix for y1a2b3c4d5e6 widen_conversation_items_pk

MySQL PKs are unnamed; batch_alter_table can't drop then add without
erroring with 'Multiple primary key defined'. Use raw DDL for MySQL
matching the pattern from r1a2b3c4d5e6.

* fix(ci): fix remaining MySQL test failures

- conversation_store search: add MySQL dialect branch using
  CONVERT(data USING utf8mb4) LIKE instead of the PostgreSQL-specific
  '::text ILIKE' cast
- test_db_models + test_conversation_store: CHECK constraint violations
  raise OperationalError on MySQL (code 3819), not IntegrityError;
  update test_check_constraint_* and workspace-check tests to accept
  both

* fix(ci): all store+db tests pass on MySQL

- permission_store: add MySQL dialect branch in grant() and ensure_user()
  using ON DUPLICATE KEY UPDATE (mysql_insert) instead of PostgreSQL-
  specific OnConflictDoUpdate/OnConflictDoNothing
- conversation_store search: replace 'ci.data::text ILIKE' (Postgres-only)
  with CONVERT(ci.data USING utf8mb4) LIKE on MySQL
- test_db_models: CHECK constraint violations raise OperationalError on
  MySQL (code 3819) not IntegrityError; accept both in check constraint tests
- test_conversation_store: same fix for workspace CHECK constraint tests

682 passed, 3 skipped locally against MySQL.

* style: ruff format

* perf(ci): session-scoped DB per worker + mysqlclient for MySQL tests

- conftest: add session-scoped _worker_db_uri fixture that creates one
  database per xdist worker (not per test) and runs Alembic migrations
  once. The per-test db_uri fixture truncates tables between tests for
  isolation. This reduces migration runs from ~680 to 4.
- Remove FOREIGN_KEY_CHECKS toggles around TRUNCATE — all FKs were
  dropped in p1a2b3c4d5e6 so the toggles are pure overhead.
- CI: install libmysqlclient-dev + mysqlclient (C extension driver)
  instead of pure-Python pymysql, and switch dialect to mysql+mysqldb.
  mysqlclient is significantly faster per round-trip.
2026-07-09 09:10:18 +00:00
Tomu Hirata 1d410b8583 fix(policy-hook): log reauth failure reasons and proactively refresh lapsed bearer (#2192)
* fix(policy-hook): improve reauth logging and proactively refresh lapsed bearer

The baked one-shot hook token was silently failing: all exceptions in
_reauth() were swallowed with no stderr, making it impossible to tell
whether the factory import failed, no credential was available, or the
mint itself threw. Add distinct log lines for each failure path.

Proactively re-mint the bearer before the first evaluate POST when the
JWT exp claim shows the token is within 5 min of expiry (or already
lapsed). Handles the "runner older than ~1h" case without waiting for a
401/302 — the one-shot reauth fires before the request rather than as
a recovery.

* fix(policy-hook): drop proactive reauth — only improve failure logging

Proactive JWT expiry check was not fixing the actual failure pattern:
when reauth() returns None (the bug case), proactive fires first,
gets None, and the session still fails closed — same outcome as before.
Remove it.

Keep only the logging improvements: each _reauth() failure path now
prints a distinct stderr message instead of silently returning None.

* fix(policy-hook): surface reauth failure reason in the UI error message

Hook subprocess stderr is discarded by the harness, so the reauth
failure reason was silently lost. Convert the inner _reauth() closure
to PolicyHookReauth — a callable class that records failure_reason on
each None return. Thread the reason through fail_closed_hook_output()'s
new detail param so it appears in permissionDecisionReason (the field
shown to the user in the UI) and in the block reason for
UserPromptSubmit.

Before: "Omnigent policy evaluation unavailable (could not reach or
authenticate to the Omnigent server); failing closed for this tool call."

After: "...failing closed for this tool call. Detail: no credential
resolved (no stored token and no Databricks SDK auth for '...')"

* fix(policy-hook): surface API error details in fail-closed UI message

post_evaluate_with_retry now returns (response, error) instead of
response | None. The error string captures the last failure reason
(4xx status + body preview, connection error, read timeout, budget
exhausted) so callers can include it in the deny/block reason shown
to the user — alongside the existing reauth failure detail.

Before: "...failing closed for this tool call."
After:  "...failing closed for this tool call. Detail: server returned
         403: <body>" / "connection error: ..." / etc.

All call sites updated (claude/kimi/codex/hermes/cursor). Cursor keeps
its fail-open policy on network error (no detail surfaced there since
nothing is blocked). Tests updated to unpack the tuple and assert on
the error field.

* test(policy-hook): relax fail-closed reason assertion to startswith

The reason now includes a "Detail: ..." suffix when an API error is
captured, so exact equality fails. Use startswith to check the base
message without coupling to the appended detail.
2026-07-09 08:49:20 +00:00
Daniel Lok 0f8d2288e9 feat(benchmarks): add fork, comment, and runner-file-read journeys (#2284)
* feat(benchmarks): add fork, comment, and runner-file-read journeys

Extend the dev perf harness (dev/benchmarks/omnigent) with three more
user journeys:

- fork_session — POST /v1/sessions/{id}/fork then DELETE (pure HTTP)
- add_comment — POST /v1/sessions/{id}/comments (pure HTTP + DB)
- read_runner_file — GET .../environments/default/filesystem/{path},
  the server → runner filesystem read proxy (needs a runner, no LLM turn)

fork and comment follow the existing runner-free journey pattern. The
runner-file read needs a bound runner: give runner-mode bundles an os_env
block so the runner can materialize the default filesystem environment
(without it the proxy 404s), and point the runner workspace at the temp
dir so planted files don't leak into the launch cwd.

Subagent spawn is left as a follow-up (recorded in the README) — it needs
mock-LLM tool-call scripting and parent/child auto-wake polling.

Co-authored-by: Isaac

* refactor(benchmarks): exclude fork DELETE from the timed span

The fork journey deleted each fork inline inside measure, folding the
DELETE into the timed op. Collect fork ids in the journey context and
delete them in teardown instead, so only the fork POST is measured.

Co-authored-by: Isaac
2026-07-09 16:45:53 +08:00
Pat Sukprasert 14ffae4672 docs(harness-bench): explain each probe in plain terms + example output (#2283)
Add a "What each probe does" table describing the six P0 dimensions
(Basic turn, Streaming, Tool calling, Policy DENY, Model override,
Interrupt) in layman's language, plus a verdict-glyph key so a reader
who has never seen the bench can read a matrix. Also add an example
--rich run of the SDK harnesses on the oss profile, showing how a
diagnosed `·` SKIP (codex / Policy DENY) reads against the Notes line.

Docs only; no code change.
2026-07-09 16:31:05 +08:00
Bryan Li dfa856f6dd feat(images): publish a kubernetes server image variant (omnigent-server-kubernetes) (#2124)
* feat(images): ship the kubernetes extra in the published server image

The kubernetes managed-sandbox provider is in the base package, but the
published omnigent-server image is built with no extras — the launcher's
lazy kubernetes-client import fails on the first managed launch, so no
official image can actually drive sandbox.provider: kubernetes. Default
OMNIGENT_EXTRAS to kubernetes (openshell variant becomes
openshell,kubernetes to stay a superset), and drop the sandbox-runners
overlay's mandatory self-built-image override now that the official
image works as-is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(images): publish a kubernetes server variant instead of folding the extra into base

Keep the published omnigent-server image lean (OMNIGENT_EXTRAS stays
empty) and instead publish ghcr.io/omnigent-ai/omnigent-server-kubernetes,
mirroring the openshell variant end to end: tags, build step, SBOM,
nightly promotion, and floating-tag reconcile. The sandbox-runners
overlay swaps the base image for the variant via its images: block, so
`kubectl apply -k` works against official images with no self-build.

Co-authored-by: Isaac

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Bryan Li <15131870+btli@users.noreply.github.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-09 08:17:37 +00:00
Zeyi (Rice) Fan e69af6b358 🐛 fix(ios): Prevent media permission crashes (#2282)
## Related issue

N/A

## Summary

- Add `NSCameraUsageDescription` and `NSSpeechRecognitionUsageDescription` usage strings (Debug + Release Info.plist) so iOS doesn't crash when the WebView requests camera or speech-recognition access.
- Gate WebKit media capture with `isAllowedMediaCaptureType`, allowing camera, microphone, and cameraAndMicrophone (previously microphone-only) and still only for the pinned app origin.
- Repair duplicate `PrivacyInfo.xcprivacy` object IDs in the Xcode project so the iOS target compiles.

## Test Plan

- Added `AppPrivacyInfoTests.testPrivacyUsageDescriptionsArePresent` asserting the camera, microphone, and speech-recognition usage strings are present and non-empty in the app bundle.
- Built the iOS target (duplicate object IDs previously broke the build) and exercised the camera/mic capture prompt via the WebView.

## Demo

N/A

## Type of change

- [x] Bug fix
- [ ] Feature
- [x] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Unit test verifies the required iOS privacy usage strings are present. Manual verification: built the iOS target and confirmed the camera/microphone capture prompt no longer crashes and is granted only for the pinned origin.

## Changelog

[UI] Fix iOS crash when granting camera or voice-dictation permission in the app
2026-07-09 07:59:50 +00:00
Pat Sukprasert 85f29f539a fix(cli): omni run --harness acp:<slug> — valid agent name, slug preserved (#2280)
`omni run --harness acp:<slug>` (a configured ACP agent, e.g. acp:qwenacp)
failed at spec synthesis: _materialize_harness_launcher_file put the harness id
straight into the agent `name`, and the agent-name validator rejects the colon
("name must match [a-zA-Z0-9_-]+"). The generic ACP harness (#2152) intends
acp:<slug> as the run-time addressing form (canonicalizes to `acp`, command
resolved from the acp: config block at spawn), but this no-AGENT launcher path
was missed.

Fix: keep the FULL acp:<slug> in executor.harness (canonicalize_harness drops
the slug to bare `acp`, which would lose the agent selection), and sanitize the
colon (":" -> "-") for the agent NAME and temp filename only, which must be
[a-zA-Z0-9_-]+ / path-safe. Non-acp harnesses are unchanged: name still uses the
raw input (claude -> "claude"), executor/filename still canonicalize (claude ->
claude-sdk, kimi alias -> kimi). Added an acp:<slug> launcher test; existing
launcher tests green.
2026-07-09 07:54:46 +00:00
Serena Ruan ea5a2ce441 feat(web): auto-fill a configurable default base branch for new worktrees (#2267)
* feat(web): auto-fill a configurable default base branch for new worktrees

When naming a new worktree branch in the new-session composer, users had
to type the base branch every time. Add a "Default base branch" setting so
the base-branch field pre-fills automatically.

- New Settings › Git section with a "Default base branch" text input,
  persisted per-device in localStorage (omnigent:default-base-branch),
  mirroring the existing appearance/font preference modules. Blank = no
  auto-fill (worktrees branch off current HEAD, unchanged behavior).
- The composer seeds its base-branch state from the stored default, so the
  field appears pre-filled once a new branch name is entered.

Also reset the module-level landingDraft in the flow test's beforeEach to
stop composer state leaking across tests.

Co-authored-by: Isaac

* fix(web): stop stale base-branch auto-fill after clearing the default

The landing composer snapshots its fields into a module-level draft on
unmount. An auto-filled default base branch was captured in that snapshot
and, on remount, took precedence over the live setting — so clearing (or
changing) the Default base branch in Settings still left the old value
auto-filling the field.

Track whether the user actually edited the base branch. The draft now only
pins the base branch on a real edit; otherwise the field mirrors the current
default, so clearing or changing the setting takes effect immediately. A
user-typed base still survives a nav-away.

Co-authored-by: Isaac

* fix(web): refresh base-branch default when the worktree popover reopens

Changing the Default base branch in Settings and returning to the composer
didn't auto-fill until a full refresh: a same-tab settings change fires no
`storage` event, and the composer's mount-time seed can hold a stale value.

Re-read the configured default when the worktree popover opens, unless the
user has hand-typed a base. The field now reflects the current setting the
next time it's opened, without a refresh; a user-typed base is left intact.

Co-authored-by: Isaac

* fix(web): live-follow the base-branch default via a change subscription

The popover-open re-read missed same-tab settings changes when the composer
stayed mounted. Replace it with an explicit subscription: writeDefaultBaseBranch
announces same-tab changes on a custom event (the `storage` event only fires
in other tabs), and the composer follows the default while the user hasn't
taken over the field.

Encodes four rules, each covered by a test:
1. Nothing set → no auto-fill; the user types freely without side effects.
2. User already filled a base → a later setting change leaves it untouched.
3. Branch named, base empty → a setting change auto-fills it, still editable.
4. Once the user edits the base (even to blank), the default never touches it.

Co-authored-by: Isaac

* fix(web): re-seed the base branch from the default on each dropdown open

Simplify the model: the base-branch field is re-seeded from the Settings ›
Git default (or blank) every time the worktree dropdown opens, and never
remembers a value typed in a previous open. Within one open the user can
override it freely; reopening discards that and shows the setting again.

Drops the persisted baseBranch/baseBranchEdited draft state and the same-tab
change subscription — reading on open covers every case (change, clear, or
prior edit) without stale-state pitfalls.

Co-authored-by: Isaac

* fix(web): tie base-branch auto-fill to the branch-name lifecycle

Seed the base branch from the Settings › Git default when the user names a
new-worktree branch, then leave it to the user: any edit — including
explicitly clearing the field — stands, even when the worktree dropdown is
reopened. Clearing the branch name (starting the worktree over) re-arms the
auto-fill, so the next named branch seeds fresh from the current default.

Previously the field re-seeded on every dropdown open, so a base the user
had cleared came back on reopen.

Co-authored-by: Isaac

* fix(web): normalize the default base branch on read

Trim on read and treat a whitespace-only value as unset, so a hand-edited or
stale localStorage entry can't display un-normalized. Everything the app
writes is already trimmed; this closes the gap for values that bypassed the
writer. Addresses a non-blocking note from the automated PR review.

Co-authored-by: Isaac
2026-07-09 15:50:46 +08:00
Pat Sukprasert 7044f0c091 ci: split runner + stores out of Pytest (misc) shard (#2276)
Pytest (misc) had grown to ~9:52 wall, ~2x the next-slowest group and
the critical path of the matrix. Root cause (from JUnit + per-worker
progress artifacts of a main run): misc runs --dist=loadfile, which
pins a whole file to one worker, and tests/runner/test_app_sessions_native.py
alone (~506 cpu-seconds, 249 tests) set the wall floor -- 507 of 508s
on the critical worker while the other 7 finished in 264-310s and idled.

cpu breakdown of misc: tests/runner 36%, tests/stores 32%, tests/db 15%
(= 83%). The top-level *_native* coding-agent files everyone suspects
were only ~8% combined.

Carve tests/runner (runner-app) and tests/stores (stores) into their
own worksteal shards; misc ignores both and also gains worksteal so the
biggest remaining file can't re-pin a worker as the catch-all grows.
Both dirs' conftests are function-scoped, so fanning a file across
workers is safe. tests/db stays in misc (it's split by the databricks
marker, not by path).

Collection partitions exactly (-m "not databricks"):
misc_after 4425 + runner 1125 + stores 429 = 5979 = misc_before.

Also add the two new shard names to merge-ready/required.sh so they
gate. NOTE: required.sh is a generated file (replaced on internal sync)
-- the generator source needs the same two names or this hand-edit is
reverted on the next sync.

Co-authored-by: Isaac
2026-07-09 15:47:19 +08:00
Tomu Hirata 91d6746b44 feat(cli): add omnigent debug logs command (#2273)
* feat(cli): add `omnigent debug logs` command

Exposes runner, server, and CLI diagnostic log files via the debug
subgroup so operators can inspect them without navigating the
~/.omnigent/logs/ directory manually.

  --type [runner|server|cli]  which log category (default: runner)
  --list                      list files with sizes and timestamps
  -n / --lines N              tail last N lines (0 = whole file)
  -f / --follow               stream in real-time (tail -f)

* feat(cli): filter runner logs by session id

Embeds the session id in each runner log filename
(runner-conv_abc123-<random>.log) so all relaunches for a session are
discoverable. Adds --session SESSION_ID to `omnigent debug logs` to
show all log files for a session oldest-first.

* fix(cli): address Polly review on debug logs command

- Separate runner into two types: runner (logs/runner/, local CLI) and
  host-runner (logs/host-runner/, host daemon) — fixes the blocking bug
  where the default type pointed at the wrong directory
- Broaden server glob to *server*.log to cover both server-*.log
  (omnigent run) and local-server-*.log (background daemon)
- Scope --session to --type host-runner only (where session ids are
  embedded in filenames)
- Guard --follow on Windows with IS_WINDOWS check
- Add min=0 bound to --lines to reject negative values
2026-07-09 07:38:13 +00:00
Bryan Li 0d49253e78 feat(sandbox): let node_selector override the k8s runner arch default (#2123)
The kubernetes launcher forced kubernetes.io/arch: amd64 onto every
runner Pod because the host image used to publish amd64-only. The image
is now a multi-arch manifest list (amd64 + arm64), so the hard pin only
blocks scheduling on arm64 nodes. Keep amd64 as the default — existing
deployments keep their placement — but merge it first so an operator
kubernetes.io/arch entry in sandbox.kubernetes.node_selector wins.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 07:34:35 +00:00
Pat Sukprasert de1a268ff5 feat(harness-bench): bind any registered harness by name (ACP + community plugins) (#2265)
* feat(harness-bench): bind any registered harness passed by name

The bench could only probe an official profile (the 4 SDK harnesses +
auto-derived native-tui) or a dotted module:attr BenchProfile reference. A
harness registered in the omnigent registry but neither official nor native-tui
-- the in-repo generic ACP harness (`acp`, ACP_SUBPROCESS), or an entry-point
community plugin (`rovo`/`rovo-cli` from omnigent-rovo) -- KeyError'd on
resolve_profile, so `--harness acp` / `--harness rovo` could not run.

Add a registry fallback to resolve_profile: after the official + reference
checks, derive a BenchProfile for any harness in the omnigent registry
(_registry_profile in manifest.py). It resolves aliases (rovo -> rovo-cli),
keys off harness_modules() so it covers plugins that declare no capabilities
entry, maps integration_mode -> transport family (SDK/CLI/ACP subprocess ->
sdk-inproc family = the existing drivers; NATIVE_TUI -> native-tui), and
skip-gates on the harness's install-spec binary when present (rovo -> acli).

No new transport driver: an ACP harness registers as an omnigent agent
(config.harness=acp:<slug>) and runs on the existing SDK-wrap drivers. Both
harnesses are OWN_AUTH, so they run only where their vendor binary is installed
+ authed, and skip cleanly otherwise (verified live: rovo skips on missing
`acli`). tool_calling/policy_deny stay `·` for ACP (agent runs its own tools /
gates via session/request_permission) -- the same documented gap as native.

Tests: resolve_profile binds acp (sdk-inproc) and rovo/rovo-cli (alias, acli
gate); unknown still KeyErrors; plugin cases skip if omnigent-rovo absent.
Offline suite 71 passed / 18 skipped, ruff clean.

* fix(harness-bench): address review — NATIVE_SERVER refusal, own-auth model, ACP-login SKIP

Three fixes from PR review + a live rovo run:

1. (blocking, Polly) A MODELED integration_mode the bench has no driver for
   (NATIVE_SERVER, e.g. opencode-native) was silently degrading to the
   sdk-inproc default via `.get(mode, "sdk-inproc")` — binding a vendor-server
   harness to the wrong driver and dropping its skip-gate. _registry_profile now
   distinguishes: no caps (unmodeled plugin) -> assume SDK family; a modeled
   mode NOT in the transport map -> return None so resolve_profile KeyErrors
   (honest "unrunnable" rather than a wrong profile). resolve_profile("opencode
   -native") KeyErrors again.

2. A live rovo run (acli absent) reported `!!✓>✗` DRIFT: the ACP-session /
   vendor-login failure ("Ensure `acli` is installed and you are logged in",
   "AcpProcessExited", "ACP subprocess/session") wasn't an infra marker, so it
   read as a real UNSUPPORTED against the SUPPORTED declaration. Added those
   markers + a reason so an own-auth harness with no vendor login SKIPs (env
   gap), never drifts.

3. Registry profiles stamped a databricks-* placeholder model even for own-auth
   harnesses (rovo/acp), which is misleading — the runner drops the gateway
   model for them. Now: gateway-credential harness -> the databricks default;
   own-auth or capless -> empty model (the harness owns it).

Tests: NATIVE_SERVER refusal; a plugin-independent happy-path (fake registered
CLI harness via monkeypatch) so the fallback's positive path isn't skip-gated
away in CI; rovo model=="" assertion. Offline suite 73 passed / 18 skipped.

* fix(harness-bench): registry profiles need a valid model to register

My previous "empty model for own-auth" change broke agent registration: the
omnigent executor spec mandates a model (spec/omnigent.py: "executor.type=
'omnigent' requires a model"), so model="" -> 400 "llm.model must be present
when llm block is present" on register_agent. Seen live: rovo got past auth +
skip-gate into provisioning, then failed registration.

A model is always required for registration, so stamp the databricks default in
all cases. For an own-auth harness it is inert: the generic ACP harness drops
databricks-* models (workflow.py::_build_acp_spawn_env), and rovo has no
spawn-env builder + reads HARNESS_ROVO_MODEL directly from env (which the runner
never sets for it), so rovo gets no model and lets Rovo Dev pick its own default
at session/new. The placeholder satisfies registration and never reaches acli.

Tests updated to assert a non-empty model (registration invariant) rather than
empty.

* feat(harness-bench): bind acp:<slug> ids to a specific ACP agent

`acp:<slug>` is a first-class omnigent harness id — the base `acp` harness is
registered and the slug selects a user-configured ACP agent at spawn (resolved
from the ~/.omnigent `acp:` block). The registry fallback now recognizes it:
look up caps/module/install-spec by the base `acp`, but keep the full `acp:<slug>`
as the profile harness so `config.harness=acp:<slug>` reaches the runner, and
sanitize the colon in the env-prefix/marker stem (acp:qwen -> HARNESS_ACP_QWEN_).
An empty slug ("acp:") is refused.

Lets `--harness acp:qwen` bind to a specific ACP agent for a live turn (qwen is
installed + authed), vs the bare `acp` which needs HARNESS_ACP_COMMAND. Test
added. Offline suite 73 passed / 18 skipped.

* fix(harness-bench): sanitize colon in bench agent name for acp:<slug>

The bench built its agent name as bench-<harness>, but an acp:<slug> harness id
has a colon, which the agent-name validator rejects ([a-zA-Z0-9_-]+). So a
--harness acp:qwen run would 400 at registration. Replace ":" with "-" in the
NAME only (bench-acp-qwen); config.harness keeps the real acp:<slug> id so the
runner still resolves the right ACP agent at spawn.
2026-07-09 15:30:57 +08:00
Tomu Hirata 49a649f19a chore: remove dead cost_advisor / cost_judge runner-side feature (#2266)
* chore: remove dead cost_advisor / cost_judge runner-side feature

No agent YAML ever used `executor.config.cost_optimize:`, making the
entire runner-side per-turn cost advisor a dead code path. The feature
was superseded by the server-side smart routing (OMNIGENT_SMART_ROUTING).

Deleted:
- omnigent/runner/cost_advisor.py
- omnigent/runner/cost_judge.py
- tests/runner/test_cost_advisor.py
- tests/runner/test_cost_judge.py
- tests/e2e/test_polly_cost_advisor_e2e.py

Cleaned up:
- omnigent/runner/app.py: remove AdvisorTurnResult import, _fetch_cost_control_mode_override,
  _merge_advisor_note, _apply_advisor_to_body, _session_advisor_applied_model,
  _run_turn_advisor, _emit_routing_decision, _apply_advisor_for_turn,
  _advisor_spec_for_session, and both call sites in the turn paths.
- omnigent/spec/parser.py: remove cost_optimize from _STRUCTURED_EXECUTOR_CONFIG_KEYS.
- omnigent/cost_plan.py: strip to just COST_CONTROL_LABEL_NAMESPACE and
  reserved_cost_control_keys (still used by sessions.py for the label
  namespace guard); remove all advisor-only symbols.
- tests/runner/test_app_sessions_native.py: remove advisor integration tests.

* fix(ci): remove test_cost_plan.py, fix test_sessions_cost_labels imports

* fix: revert accidental Sidebar.tsx change; fix dangling cost_advisor doc refs

* chore: regenerate openapi.json for updated RoutingDecisionData docstring

* chore: remove tier from RoutingDecisionData and full frontend pipeline

* fix: re-delete cost_advisor.py (re-appeared in working tree)

* fix(test): remove routing_decision.tier assertion after field removal
2026-07-09 06:55:13 +00:00
Zeyi (Rice) Fan eae151dff7 🐛 fix(ios): Keep modals within the visible viewport when the keyboard opens (#2263)
## Related issue

N/A

## Summary

- Modals (e.g. Create custom agent) are `position: fixed`, centered with
  `top-1/2 -translate-y-1/2`, and capped at `max-h-[85vh]`. On the iOS
  shell the native app keeps the WKWebView layout viewport full-height
  when the soft keyboard opens (`.ignoresSafeArea(.keyboard)`), so `vh`
  and `50%` both resolve against the whole screen — the modal's lower half
  (and any focused input) ends up hidden behind the keyboard.
- Fix in the shared `DialogContent` primitive so every modal benefits at
  once: on the iOS shell only, an inline style pins the centering origin
  and height cap to the keyboard-aware `--omnigent-viewport-height` (which
  `useIOSViewportLock` already publishes on :root from
  `visualViewport.height`), less the safe-area insets and a small margin.
  The modal now shrinks and its inner content scrolls; nothing extends
  behind the keyboard, notch, or home indicator.
- Inline style is deliberate: the several dialogs that pass their own
  `max-h-[85vh]` would otherwise win, since `cn`'s twMerge keeps the
  caller's class. Inline beats classes, so the keyboard-aware cap governs.
- Gated on `isIOSShell()` and carries a `100lvh` fallback, so web,
  Android, and Electron keep the existing `85vh` / centered behavior
  unchanged.

## Test Plan

- `npx tsc -b` — clean.
- `npx vitest run` on the new `dialog.test.tsx` plus dialog-consuming
  suites (`PoliciesPage`, `NewChatDialog`) — 143 passing, including new
  coverage that the iOS inline cap (top + maxHeight from
  `--omnigent-viewport-height`) is applied inside the iOS shell and absent
  off it.
- `src/components/ui` is excluded from oxlint (vendored shadcn), so no
  lint applies to the changed primitive; prettier run on both files.

## Type of change

- [x] Bug fix
- [ ] Feature
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

The gating logic (iOS-shell-only inline cap wired to the keyboard-aware
viewport var) has unit coverage in the new dialog.test.tsx, and existing
dialog-consuming suites confirm no regression off iOS. The actual
keyboard-overlap behavior is WKWebView-specific and can't be reproduced
in jsdom (no soft keyboard / visualViewport resize), so final visual
confirmation on the iOS app — opening a tall modal with the keyboard up
and checking it stays fully on screen and scrolls internally — is still
recommended before release.
2026-07-09 06:46:58 +00:00
Zeyi (Rice) Fan 4dbe259d3e feat(ci): Add manual Electron build workflow for Linux and Windows (#2264)
## Related issue

N/A

## Summary

- Add `.github/workflows/electron-build.yml`, a `workflow_dispatch`-only
  pipeline that packages the Electron desktop shell (`web/electron`) for
  Linux and Windows. A 2-way matrix builds each platform on its own native
  runner (`ubuntu-latest` → AppImage + .deb, `windows-latest` → NSIS .exe)
  since electron-builder does not reliably cross-compile installers, and
  uploads the distributables as workflow artifacts (14-day retention).
- Reuses the repo's `./.github/actions/setup-node` composite action (pinned
  to Node 22 per web/electron/README.md, npm cache keyed on the electron
  lockfile), runs `npm ci` then `npm run build:linux`/`build:win`. Builds
  are unsigned (`CSC_IDENTITY_AUTO_DISCOVERY=false` so a missing cert
  doesn't fail the build) and never publish; macOS is omitted (its
  signed/notarized build lives elsewhere). `fail-fast: false` so one
  platform breaking still yields the other's installers.
- Fix `web/electron/package.json` metadata the Linux `.deb` build requires:
  add `homepage`, expand `author` from a bare string to `{ name, email }`,
  and set `linux.maintainer`. Without these, electron-builder's fpm packager
  aborts the `.deb` target ("specify project homepage / author email /
  .deb maintainer") — a pre-existing config gap the new Linux job would hit.

## Test Plan

- `actionlint .github/workflows/electron-build.yml` — clean.
- Validated the workflow YAML and package.json parse (yaml.safe_load /
  JSON.parse).
- Locally in `web/electron`: `npm ci` resolves cleanly, and
  `npm run build:linux -- --publish never` produces BOTH
  `Omnigent-<ver>-<arch>.AppImage` and
  `omnigent-desktop-electron_<ver>_<arch>.deb` after the metadata fix
  (before it, the .deb target failed as described above). Confirmed the
  workflow's artifact globs (`*.AppImage`, `*.deb`, `*.exe`) match the
  real output names.

## Type of change

- [ ] Bug fix
- [x] Feature
- [ ] Refactor / chore
- [ ] Docs
- [x] Test / CI
- [ ] Breaking change

## Test coverage

- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

CI workflow + build-config change with no unit-testable surface; verified
by linting the workflow (actionlint) and by running the Linux build locally
end-to-end, which produced both the AppImage and .deb and proved the
package.json metadata fix. The Windows job could not be exercised locally
(macOS host), but it uses the same already-working `build:win` (nsis) script
on `windows-latest`; the first manual run from the Actions tab will confirm
it end-to-end.
2026-07-09 06:34:25 +00:00
Zeyi (Rice) Fan 86ad734963 🐛 fix(ios): Copy button, copy confirmation, and status-line overlap (#2262)
## Related issue

N/A

## Summary

Three related fixes to the mobile / iOS chat surface:

- **Message copy button now works on mobile.** The user and assistant
  bubble copy actions called `navigator.clipboard.writeText` directly and
  silently no-op'd when it was absent (the iOS webview / non-secure
  origins). They now route through the shared `copyText()` helper, which
  falls back to an `execCommand` textarea copy. Deduplicated the two inline
  handlers into a shared `useCopyMessage` hook.
- **Visual confirmation on copy.** On a mobile viewport the copy action
  fires a "Copied to clipboard" toast in addition to the inline check icon
  (which is easy to miss on a phone). Desktop is unchanged (icon + tooltip).
- **Native Chat/Terminal bar no longer disappears after copy.** The
  `execCommand` fallback focuses a hidden textarea, which the iOS
  keyboard-visible check mistook for the keyboard opening and hid the
  native Liquid Glass bar — and WebKit doesn't reliably fire `focusout`
  when the focused node is removed, so it stayed hidden. The helper textarea
  is now marked `data-clipboard-helper` and excluded from editable-focus
  detection.
- **iOS Chat/Terminal bar no longer overlaps the composer status line.**
  The chat-view bottom spacer reserved 1rem less than the bar's footprint,
  so the bar rode up over the host / harness / context-ring row. It now
  reserves the full footprint (iOS-only, chat-view-only).

## Test Plan

- `npx tsc -b` — clean.
- `npx oxlint` on changed files — no new findings.
- `npx vitest run` on the affected suites (clipboard, keyboard-inset hook,
  ChatPage user bubble) — 23 passing, including new coverage:
  - clipboard-helper textarea is not treated as editable focus, while a
    real textarea is;
  - copy falls back to `execCommand` when the async clipboard is absent;
  - a mobile viewport fires the copy toast;
  - the fallback textarea carries the `data-clipboard-helper` marker.
- CSS + WKWebView-specific behavior verified by inspecting the Vite-served
  compiled CSS; on-device visual confirmation still pending (see notes).

## Type of change

- [x] Bug fix
- [ ] Feature
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

The clipboard, keyboard-inset, and copy-button paths have unit coverage
(23 tests, listed in the Test Plan). The two behaviors that can't be
exercised in jsdom — the iOS status-line/bar overlap (CSS var math) and the
real WKWebview clipboard/native-bar interaction — were verified by reading
the Vite-served compiled CSS and by reasoning from the shell's focus/keyboard
hooks; final on-device visual confirmation in the iOS app is still
recommended before release.
2026-07-09 06:29:46 +00:00
Zeyi (Rice) Fan 06ba29f83f feat(omnidev): Add --trust-lan-origins for device testing (#2261)
## Related issue

N/A

## Summary

- Add a `--trust-lan-origins` flag to omnidev (the dev-pod supervisor) so a
  phone or tablet on the same network can use the UI end to end when Vite is
  bound with `--vite-host 0.0.0.0`. A device loads the UI at
  `http://<lan-ip>:<vite-port>`, so its browser stamps that non-loopback
  address as the `Origin` on every request. The pod's backend runs in
  single-user local mode, where the origin guard trusts only loopback
  origins — so multipart uploads get a 403 and the WebSocket stream is
  refused. The flag closes that gap.
- New `lan.rs` enumerates this machine's LAN IPv4 addresses (private +
  link-local, dropping loopback/public/broadcast/multicast via the
  `if-addrs` crate) and builds the matching `http://<ip>:<vite-port>`
  origins. They're fed to the server through its own exact-match allowlist
  env var `OMNIGENT_WS_ALLOWED_ORIGINS`, merged with any value the developer
  already exports (order-preserving, deduped). It stays exact-match — only
  the enumerated origins are trusted, nothing is disabled — so it covers
  both the upload guard and the WS handshake without weakening CSRF/CSWSH
  protection. Off by default; a no-op unless the flag is passed.
- The trusted origins are printed in the combined log at startup; if the
  flag is set but no LAN interface is found, a warning says so rather than
  silently no-op'ing later.
- README documents the flag and a "Testing from a phone or tablet" section.

## Test Plan

- `cargo build`, `cargo test` (22 passing, incl. new unit tests for LAN IPv4
  filtering, origin construction, and the env-merge onto an inherited
  allowlist), `cargo clippy --all-targets` (clean), `cargo fmt --check`
  (clean).
- Verified the real `if-addrs` enumeration on this machine produces the
  expected `http://<ip>:5173` origins for the host's private/link-local
  interfaces (loopback/public dropped).
- `--help` renders the new flag; `pre-commit` passed on the changed files.

## Type of change

- [ ] Bug fix
- [x] Feature
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Origin filtering, construction, and the allowlist env-merge have unit tests
(cargo test, 22 passing). The real interface enumeration and the
device-in-browser flow can't be asserted in a unit test, so they were
verified manually: the `if-addrs` call was run on this host and produced the
correct origins, and the resulting `OMNIGENT_WS_ALLOWED_ORIGINS` value was
confirmed to merge with an inherited value. Final confirmation from an actual
LAN device (upload + live stream over `--vite-host 0.0.0.0
--trust-lan-origins`) is recommended but not automatable in CI.
2026-07-09 06:00:39 +00:00
Daniel Lok ac19316854 ci(benchmark): fix nightly timeout (cap full-turn iterations) + corpus/label tweaks (#2251)
* ci(benchmark): default items-per-session to 200

Raise the seeded items-per-session default from 50 to 200 for a denser
per-session corpus. Update both the workflow_dispatch input default and
the ITEMS env fallback used by scheduled runs so manual and nightly runs
agree on the default.

Co-authored-by: Isaac

* ci(benchmark): rename workflow to "Benchmark", clarify iterations label

Rename the workflow from "Performance Benchmark" to "Benchmark" and reword
the iterations input label to "Requests per run" so it matches how the
harness drives the journeys.

Co-authored-by: Isaac

* ci(benchmark): cap full-turn journeys' iterations; HTTP default 200->100

The nightly benchmark timed out at 30 min inside the first full-turn
journey. `--iterations` applied uniformly, but the four runner journeys
cost ~1s+ per op (vs. ~ms for the HTTP journeys), so 200 iterations x 3
runs was ~20 min for `session_cold_start` alone.

Add a `max_iterations` field to `Journey` that clamps `--iterations` down
per journey (never up), and cap the four full-turn journeys at 5 samples
per run — `--runs` provides the repeats. Splitting samples across runs
vs. iterations doesn't change accumulation (all runs share one env), so a
small per-run count is the lever; it also keeps the cold-start session
drift (~2 ms/turn, sessions accumulate within a run) negligible. Lower the
HTTP iterations default 200 -> 100 to match run.py's own default.

The full runner suite now finishes in ~2.4 min locally (was 20+ min),
with meaningful cross-run percentiles.

Co-authored-by: Isaac
2026-07-09 13:33:58 +08:00
Daniel Lok f2c1594a4a feat(doc-sync): title site PRs after the docs change, not the PR number (#2250)
The omnigent-site PR was titled `docs: document omnigent-ai/omnigent#N`,
but the source PR number already appears twice in the body, so the title
carried no information. Title it after the actual docs change instead.

The doc-drafter now emits a `DOC_PR_TITLE:` line summarizing what the docs
cover; the workflow sanitizes it (untrusted LLM output) and falls back to
the source PR title, then the old `document #N` form, so a missing line
degrades gracefully. Also pass `--title` on the `gh pr edit` update path,
which previously never refreshed a re-draft's title.

Co-authored-by: Isaac
2026-07-09 13:32:47 +08:00
Serena Ruan 760333275c fix(web): align project picker menu rows left with uniform height (#2260)
* fix(web): align project picker menu rows left with uniform height

The sidebar "Add to / Move to project" submenu had inconsistent rows: the
search box used px-2 py-1.5 while the project rows fell back to the
DropdownMenuItem default (px-1.5 py-1), so rows were indented differently
and slightly shorter than the search input. Give every row (project names,
"Create new project", "Remove from …", and the inline new-project input) a
uniform px-2 py-1 so they share one left edge and height.

Co-authored-by: Isaac

* style(web): fix prettier formatting in Sidebar.tsx

Restore the canonical multi-line union type on the drag-start cast that a
prior edit had collapsed onto one line, which prettier --check rejected.

Co-authored-by: Isaac
2026-07-09 13:27:48 +08:00
Tomu Hirata 3c7a558ce5 feat(smart-routing): replace RoutingDecisionChip with collapsible RoutingDecisionCard (#2246)
* feat(smart-routing): replace RoutingDecisionChip with collapsible RoutingDecisionCard

When auto-routing fires at first-message time (agent spec has no explicit
model), the UI previously showed a minimal muted chip. Replace it with a
collapsible card that mirrors the SmartRoutingCard style: same container
border, a model+tier pill, rationale text, and an expandable raw verdict
JSON block behind a chevron.

The chip remains exported for any downstream consumers but ChatPage now
renders RoutingDecisionCard for routing_decision bubbles.

* feat(smart-routing): mirror sub-agent routing decisions into the parent session

When sys_session_send spawns a child session without an explicit model,
the server routes it and emits a routing_decision item — but only into
the child's transcript. Orchestrators seeing the main session had no
visibility into which model was chosen for each sub-agent.

Changes:
- Add optional `agent` field to RoutingDecisionData so parent-mirrored
  items carry the sub-agent name.
- _emit_server_routing_decision accepts a keyword `agent` arg.
- Both routing paths (_forward_event_to_runner SDK path, native terminal
  path) now also emit into parent_conversation_id when _parent_routing_on,
  passing the child's agent_name as the agent label.
- Thread `agent` through the frontend pipeline: RoutingDecision event,
  RoutingDecisionBlock, RoutingDecisionItem, SSE reducer, blockStream,
  itemsToBlocks, renderItems bubble, and RoutingDecisionCard.
- RoutingDecisionCard shows the agent name as the row label (replacing
  "Session") when rendering a parent-mirrored decision.

* fix(smart-routing): remove tier label from RoutingDecisionCard pill

* chore: regenerate openapi.json for RoutingDecisionData.agent field
2026-07-09 05:18:42 +00:00
Aravind Segu 2bb916b058 refactor(db): enforce scoped uniqueness in app code, drop partial indexes (#2256)
* refactor(db): enforce scoped uniqueness in app code, drop partial indexes

MySQL has no partial (WHERE-predicated) indexes. The four scoped indexes on
agents/policies/conversations leaned on dialect-scoped sqlite_where /
postgresql_where kwargs that MySQL silently dropped, yielding full unique
indexes that over-restrict on MySQL (session agents/policies could not reuse
names there). Replace them with plain indexes that behave identically on
SQLite, Postgres, and MySQL:

- ix_conversations_parent_title_unique: kept UNIQUE, predicate dropped. The
  WHERE (parent_conversation_id IS NOT NULL) was redundant with NULL-distinct
  semantics, so top-level conversations stay exempt. No behavior change.
- idx_conversations_parent: non-unique perf index, predicate dropped. Now
  indexes every parented row; same query plan for child-session listing.
- ix_agents_template_name -> ix_agents_name (plain). Template-name uniqueness
  moves to the store (SqlAlchemyAgentStore.create gains a workspace-scoped
  pre-insert check; agents had no app-level check before).
- ix_policies_default_name_cksum -> ix_policies_name_cksum (plain). Default-
  name uniqueness was already enforced in the store (add_default /
  update_default); the index was just a backstop.

Migration z5a2b3c4d5e6 (index-only, off z4a2b3c4d5e6): drops the partials and
creates the plain replacements; downgrade restores the partials.

Co-authored-by: Isaac

* refactor(db): include kind in ix_agents_name for template lookups

Session agents can now share names, so (workspace_id, name) alone matches a
template plus every same-named session copy. Add kind to ix_agents_name ->
(workspace_id, name, kind, id) so get_by_name and the create() uniqueness
check seek straight to the template row instead of scanning session copies.

Co-authored-by: Isaac
2026-07-09 05:14:03 +00:00
Aravind Segu 64762f2979 feat(db): compress opaque text columns client-side (#2243)
MySQL's InnoDB does not compress TEXT/BLOB by default and SQLite never
does, so per-conversation JSON/text columns that PostgreSQL would TOAST
sat uncompressed on the other two backends. Compress them in the
application layer instead, for a uniform on-disk size across all three.

Add omnigent/db/compression.py: a `CompressedText` SQLAlchemy
TypeDecorator (LargeBinary impl) that zstd-compresses on write and
decompresses on read, transparent at the ORM boundary so the stores keep
reading/writing `str`. Values carry a NUL-sentinel + codec frame; sub-64B
payloads are stored uncompressed to avoid framing inflation. Rows written
before migration are unframed and decode unchanged (and on SQLite arrive
as `str`), so no backfill is needed — each re-frames on its next write.

Apply it to six columns never queried in SQL: conversations.session_usage
/ session_state / terminal_launch_args, comments.body / anchor_content,
and agents.description. Migration z4a2b3c4d5e6 flips them TEXT -> binary
via batch alter (PostgreSQL casts with convert_to/convert_from); the
downgrade decompresses every row before restoring TEXT.

Add zstandard as a dependency. Codec + migration + type-change tests
included; existing store suites pass unchanged.

Co-authored-by: Isaac
2026-07-09 04:04:23 +00:00
Serena Ruan 904aba1870 fix(sessions): keep shared project sessions out of "My sessions" (#2249)
Projects are a "My sessions"-only surface — filing a session into a
project is owner-only, so the sidebar renders project folders only on
"My sessions". But the two backend surfaces that drive the project view
filtered by any access grant rather than ownership, so a session someone
shared with you, if it carried a project label, surfaced inside its
project folder under "My sessions" instead of under "Shared with me".

Scope both project surfaces to owner-level grants:

- list_projects / GET /sessions/projects: the folder names now come only
  from projects that contain a session the viewer owns.
- list_conversations / GET /sessions?project=X: the sessions inside a
  folder are now owner-scoped too.

The flat list (project=None) and Unfiled (project="") stay unscoped, so
shared sessions still surface for the "Shared with me" tab.

Co-authored-by: Isaac
2026-07-09 11:14:56 +08:00
Pat Sukprasert bd0ebcf18d fix(harness-bench): observe native Policy DENY (deterministic reader) (#2171)
Live instrumentation (temporary, reverted) proved the native Policy DENY chain
works end to end: the claude PreToolUse evaluate-policy hook fires, reaches
/policies/evaluate, the session-attached CEL deny loads, the server returns
POLICY_ACTION_DENY with our reason and publishes response.policy_denied. The
prior "hook not wired / ap_server_url not threaded" diagnosis was WRONG — it
came from searching $HOME instead of the real bridge root
(/var/folders/.../omnigent-502/claude-native), which HAS a valid
permission_hook.json.

The real bench bug was a reader race, and a first grace-window fix was still
flaky (passed 1 run, SKIPPED the next). Root cause: response.policy_denied is
published when the PreToolUse hook evaluates, and its timing relative to the
turn's output_item.done is highly variable — it can land after a SECOND
output_item.done and the session settle. A fixed grace window measured from the
first terminal event races that.

Deterministic fix: on a deny turn the reader no longer stops on the turn's
terminal events at all — it reads until it sees response.policy_denied (returns
immediately) or the caller signals stop after a generous observe budget
(_DENY_OBSERVE_S=30s). A real deny exits early; only a genuine no-deny waits the
budget then SKIPs. Non-deny turns are unchanged (stop on the terminal event).

Live: claude-native Policy DENY now SUPPORTED across repeated solo runs (was
flaky, then ·). Verdict semantics: SUPPORTED = "the tool call was routed through
policy and a DENY verdict returned"; vendor hard-enforcement (tool actually
blocked) is a separate axis noted in the driver. Offline suite 69 passed /
18 skipped; added a test for a policy_denied that lands after the terminal event.
2026-07-09 11:10:51 +08:00
Daniel Lok 238c7660be feat(benchmarks): HTTP + full-turn performance harness (no manual schema guard) (#2202)
Re-lands the benchmark harness (reverted in #2200) without the manual
seed-schema drift guard that caused the original merge friction.

The harness: HTTP/API journeys (list/create/get session, load history, search)
and full-turn journeys (session_cold_start, warm_turn, time_to_first_token,
interrupt) driven through server + runner + a zero-latency mock LLM, all via
the in-process openai-agents SDK harness. Seeds a deterministic corpus via the
store API; SQLite + Postgres backend matrix; nightly workflow uploads a
versioned JSON report for a workspace Databricks notebook to consume.

Drops the SEED_SCHEMA_REVISION constant, scripts/check_benchmark_seed_schema.py,
and the pre-commit hook. That guard was a false-positive tripwire — it failed on
every migration (even ones not touching the seed's tables) and its "fix" was
always just bumping a string; the seed never actually broke. Instead seed() now
reads the Alembic head at runtime (_get_head_db_revision) into the corpus reuse
marker, so an old corpus auto-reseeds with zero maintenance. The real invariant
— that seeding still works against the current schema — is covered by
test_seed_creates_listable_corpus, which seeds through the store (migrations run
to head on init) and so can't false-positive.

Verified: 8 smoke tests pass; seed auto-picked up the new head (x1a2b3c4d5e6)
with no code change; --print-head intact for the CI seed-cache key; ruff, mypy,
pre-commit clean.

Co-authored-by: Isaac
2026-07-09 10:06:59 +08:00
Zeyi (Rice) Fan 9fcf2c4f9d feat(dev): omnidev manages the omnigent install; lighter pod isolation (#2242)
## Related issue

N/A

## Summary

- Add install-management subcommands to omnidev, for people who *run*
  omnigent (installed from git via `uv tool install`) rather than develop
  it. This fills a real gap: omnigent's own update notice only works for
  PyPI-wheel installs and skips git installs, so a git-installed omnigent
  never learns it is out of date.
  - `omnidev install` — `uv tool install` from git, defaulting to the
    `databricks` extra and `main`; `--ref`/`--extra`/`--no-default-extra`/
    `--repo` override and persist to `~/.config/omnidev/install.toml`.
  - `omnidev update` — reinstall the latest of the tracked ref/extras
    (`--reinstall`, required for a moving git ref).
  - `omnidev check` — the shell-hook primitive: reads a cache, refreshes
    it detached when >24h stale (never blocks the shell), and on an
    available update prints a notice and, on a TTY, prompts to update in
    the foreground. A declined commit isn't re-nagged.
  - `omnidev refresh` — the background `git ls-remote` probe.
  - `omnidev shell-hook` — emits the `eval "$(omnidev shell-hook)"` snippet.
- These subcommands need no checkout and dispatch before repo-root
  discovery, so they run from any directory; bare `omnidev` still launches
  the pod supervisor. Installing from git builds the web UI from source, so
  `install` fails early if `uv`/`npm` is missing.
- Lighten pod isolation: only omnigent's own state (`OMNIGENT_DATA_DIR`,
  `OMNIGENT_DATABASE_URI`, `OMNIGENT_URL`) is isolated per pod. The pod now
  inherits the real `HOME`, credentials, config, and uv/npm caches — which
  the agents omnigent runs need — instead of the hermetic
  `HOME`/`XDG_*`/`TMPDIR` sandbox that cut them off.

## Test Plan

- `cargo build`, `cargo build --release`, `cargo clippy --all-targets`, and
  `cargo fmt` all clean.
- `cargo test` passes 13 tests (7 new): install-spec builder for default /
  no-extras / custom ref+extras, install-config round-trip, missing-config,
  update-availability logic including decline suppression, and the 24h
  staleness window.
- Manually verified from a scratch dir with no git repo that `omnidev
  check`, `shell-hook`, etc. run without a "missing checkout" error, while
  bare `omnidev` still errors as expected; confirmed the CLI surface
  (`--help`, `install --help`, `shell-hook` output).

## Type of change

- [ ] Bug fix
- [x] Feature
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

The network- and install-driving paths (`uv tool install`, `git
ls-remote`, reading the installed tool's `direct_url.json`, the detached
refresh, and the TTY prompt) can't run in unit tests, so they were verified
manually. Pure logic — spec building, config round-trip, update-
availability and staleness decisions — is covered by `tests/install_mgmt.rs`.
2026-07-08 23:45:33 +00:00
Aravind Segu 040bfd7fed feat(db): include primary-key columns in every secondary index (#2239)
* feat(db): include primary-key columns in every secondary index

The storage standard requires every index to contain the table's
primary-key columns. Each table's PK now leads with workspace_id (the
tenant partition key) then the entity id column(s), and every store
query filters workspace_id.

Rebuild each secondary index accordingly:
- Non-unique indexes lead with workspace_id and trail the remaining PK
  id-columns, which double as the keyset tiebreaker / covering column the
  queries already use.
- Unique indexes/constraints get workspace_id prepended only (appending
  the entity id would make uniqueness vacuous), becoming per-workspace
  unique. uq_hosts_token_hash is safe because resolve_launch_token
  already filters workspace_id + token_hash.

Two orders are query-driven, not mechanical:
ix_session_permissions_conversation_id and
ix_conversation_items_response_id place the filtered PK column right
after workspace_id. ix_comments_created_at is dropped — no query sorts
comments globally by created_at (always conversation-scoped).

MySQL note: MySQL has no partial index, so the WHERE on the partial
unique indexes is dropped there and the unique spans all rows (more
restrictive; acceptable). Emulating partial-unique on MySQL is left to
the MySQL support work.

Co-authored-by: Isaac

* fix(comments): order list_for_conversation by (created_at, id)

created_at is seconds-granular, so comments added in the same second tie
under ORDER BY created_at and the listing order fell back to index scan
order. Adding id to every secondary index changed that implicit tiebreak
(rowid → id), surfacing the latent non-determinism. Sort by (created_at,
id) for a stable, deterministic order, matching the keyset convention
used by the other stores. The chronological-order test now advances the
clock per add so its "oldest first" assertion no longer hinges on the
same-second tiebreak.

Co-authored-by: Isaac

* feat(db): fold created_at into ix_comments_conversation_id

list_for_conversation now sorts by (created_at, id), so make the index
serve it: (workspace_id, conversation_id, created_at, id). This is
index-ordered for WHERE workspace_id + conversation_id ORDER BY
created_at, id and still contains the full PK. Re-adding the old bare
ix_comments_created_at would not help — the query filters conversation_id
first, so a created_at-leading index cannot serve it.

Co-authored-by: Isaac
2026-07-08 23:00:11 +00:00
Aravind Segu b7db521f2a fix(tests): stop test_interrupt_forwards flaking under misc-shard load (#2232)
The interrupt test awaited an already-unblocked task through
asyncio.wait_for(int_task, timeout=15.0). Under the misc shard's 8-worker
CPU contention the event loop can be starved past 15s, so the wall-clock
timer cancels the await even though the interrupt already returned 204 —
the traceback showed `int_task` finished with a 204 while wait_for raised
TimeoutError. This reddened the misc shard on main intermittently.

Drop the wall-clock timers: await the interrupt task and the post_seen /
fwd_seen events directly. The task is unblocked one line earlier
(fwd_gate.set()), so there is no correct reason to race it against a wall
clock; pytest's global --timeout=300 remains the genuine-hang backstop.
Widening the timeout only lowers the odds — a starvation spike past the
budget still trips it; plain await removes the race entirely.

Verified 5/5 green under all-cores-pegged + `-n 8` stress that reliably
reproduced the TimeoutError beforehand.

Co-authored-by: Isaac
2026-07-08 22:38:14 +00:00
Sabhya Chhabria 4da25975cf fix(tools): make in-process sys_timer builtin fail cleanly and share validation (#2229)
* fix(tools): make in-process sys_timer builtin fail cleanly and share validation

sys_timer_set / sys_timer_cancel firing runs in the runner: execute_tool
intercepts both and owns the per-session timer registry. The in-process
builtin, however, still carried a _spawn_timer_workflow stub that raised
NotImplementedError on its success path, plus docstrings claiming timers
were "not yet re-implemented on the runner" — a misleading contract and a
latent crash for any future non-runner dispatch path.

Extract the shared argument validation into validate_timer_set_args so the
runner firing loop and the LLM-facing builtin reject the same inputs with
one delay ceiling, replace the raising stub with a structured "no timer
scheduled" error, and correct the stale docstrings.

* test(tools): remove unused type-ignore in timer validation test

`dict[str, object]` is assignable to validate_timer_set_args's
`dict[str, Any]` parameter, so the `# type: ignore[arg-type]` was an
unused ignore that a strict MyPy run flags. Drop it.
2026-07-08 15:00:16 -07:00
Edwin He 5b40494c92 fix(web): remember the last-picked host in the new-session picker (#2218)
* fix(web): remember the last-picked host in the new-session picker

The landing composer only kept a host selection in an in-memory draft that
is dropped on create and lost on refresh, so every fresh visit re-ran the
auto-select default — the managed sandbox where it's offered, otherwise the
first online host — ignoring the host the user last picked. This is the
"always defaults to the sandbox / first host" complaint.

Persist the explicit choice in localStorage (mirroring the agent
preference) and restore it on mount: the auto-select effect now consults
the stored choice before defaulting, validating a stored host id against
the live list and falling back to the default when it's gone or offline.
The sandbox pick persists as a reserved sentinel.

Co-authored-by: Isaac

* test(web): add managed sandbox-default e2e + clarify seed comment

Address Polly review notes on the last-picked-host change:

- Add tests/e2e_ui managed variant: in a managed deployment whose default
  is the "Databricks Sandbox" option, pick a connected host, reload, and
  assert the host is restored rather than reverting to the sandbox default
  — the original complaint, now covered end to end (the OSS test already
  covered the first-online path).
- Note the intentional one-time-seed read of readLastHostChoice() so a
  future reader doesn't add it to the effect's dependency array.

Left the pre-existing managed offline-host / info-load-race edge alone:
gating the default auto-select on the /v1/info probe regresses first-paint
host selection (and the flow tests model info as a steady "loading" state),
which isn't worth a rare, pre-existing corner.

Co-authored-by: Isaac
2026-07-08 14:46:01 -07:00
Dhruv Gupta c2822b389a feat(acp): generic ACP harness + Omnigent-tool MCP bridge for all ACP harnesses (#2152)
* feat(acp): generic ACP harness + Omnigent-tool MCP bridge for all ACP harnesses

Add a generic `acp` harness that connects Omnigent to ANY agent speaking the Agent Client Protocol (gemini --experimental-acp, @zed-industries/claude-code-acp, goose, qwen, custom in-house agents). Users register named agents in an `acp:` config block via `omnigent setup`; each surfaces as its own harness-picker row (`acp:<slug>`) and drives one well-tested ACP client. Generalized from the existing (duplicated) goose/qwen ACP executors; no new dependency.

Also expose Omnigent's builtin tools (sys_*, load_skill, web_fetch, policy tools) to ALL three ACP harnesses (acp, goose, qwen) via ACP's native session/new.mcpServers, reusing the shared serve-mcp stdio relay the native harnesses use — tool calls route through ctx.dispatch_tool so Omnigent policy is enforced. Shared helper omnigent/inner/_acp_omnigent_mcp.py; global kill switch OMNIGENT_ACP_MCP=0 (generic acp also has a per-agent omnigent_mcp flag).

Routing: the registry stays one `acp` harness; a configured agent is addressed as `acp:<slug>` (canonicalizes to `acp`), command resolved from config at spawn. Improvements over the goose path baked into the generic client: tool-call cards, reasoning (agent_thought_chunk), and a real interrupt via ACP session/cancel.

Tests: unit + a hermetic fake-ACP-agent e2e (handshake -> stream -> tool card -> permission -> completion, no vendor binary) + a real relay start/teardown; goose/qwen/claude_native_bridge/capabilities regressions green.

Co-authored-by: Isaac

* fix(acp): resolve CI failures + address AI-review comments

CI: ruff-format all touched files (pre-commit); move 'Custom ACP agent' to the end of the configure-harnesses list + update the position/priority tests; add 'acp' to the harness-readiness map expectations (config-gated, not CLI-gated); exclude the generic 'acp' harness from the no-agent live-binary matrix (it has no fixed binary).

AI review: comment the two expected-shutdown empty-except blocks in acp_executor; use module _logger instead of a redundant local 'import logging' in harness_plugins.harness_catalog; drop an unused fake_rpc in the acp tests.

Co-authored-by: Isaac

* feat(acp): list each configured ACP agent as its own configure-harnesses row

Previously the setup 'configure harnesses' overview showed a single 'Custom ACP agent' row and the individual agents were buried in the drill-in. Now each configured ACP agent gets its own top-level row (alongside the built-in harnesses), plus an 'Add custom ACP agent' row — matching the web picker, which already lists each acp:<slug>. All rows route to the shared ACP manager (add/edit/remove); a per-agent edit drill-in is a follow-up. No agents configured → unchanged single 'Custom ACP agent' row.

Co-authored-by: Isaac

* fix(acp): per-agent remove + straight-to-add in configure-harnesses

Addresses UX feedback on the ACP rows: (1) the Add row jumps straight into the add flow (prints examples, then prompts) instead of a second add/remove menu; (2) it renders with no ✗ glyph (new 'action' status kind); (3) Remove now lives on each agent's own row via a per-agent drill-in (_manage_acp_agent). Deletes the now-unused combined _manage_acp_harness / _remove_acp_agent.

Co-authored-by: Isaac
2026-07-08 21:38:36 +00:00
Aravind Segu fbe38632a1 chore(db): index conversations by runner_id (#2231)
Reconnect/relaunch reconciliation looks up a runner's session(s) by
`runner_id` via `list_conversations_by_runner_id`. Four server call
sites drive that query (see omnigent/server/app.py), but `runner_id`
was unindexed, so each lookup was a full table scan of `conversations`.

Add `ix_conversations_runner_id` on `conversations.runner_id`, mirroring
the other single-column lookup indexes on this table, plus migration
z2a2b3c4d5e6 to create it. Extend the migration workspace test to assert
the index is present at head.

Co-authored-by: Isaac
2026-07-08 21:08:11 +00:00
Zeyi (Rice) Fan f1226aaa51 fix(claude-native): attach observed capture, not a post-timeout one, to readiness error (#2157)
## Related issue

N/A

## Summary

- `_wait_for_claude_prompt_ready` raised its "terminal did not become
  ready" error with the tail of a **fresh** capture taken *after* the
  30s deadline. That frame is a different moment than any of the ~200
  poll decisions the loop actually made — it can show a healthy,
  box-present composer while the real failure was 30s of box-absent (or
  empty) captures. The mismatch makes the error actively misleading:
  triaging one such failure sent us chasing footer-height, prompt-glyph,
  and box-rule theories that the attached frame contradicted.
- Attach the **last non-empty capture the loop observed** instead, and
  report the poll count and empty-capture count in the message. Those
  counts separate the two failure modes that previously looked
  identical: mostly-empty captures point at a torn read under a busy
  mid-turn repaint (session alive, `capture-pane` came back blank),
  while non-empty captures with no box point at Claude never rendering
  the prompt (a boot crash whose text the tail then surfaces).
- Poll loop is now do-while so `timeout_s=0` still checks once and always
  yields a capture to attach on failure.
- Observability-only: this does not change when the gate passes or fails,
  so it does not by itself stop a dropped message — it makes the next
  occurrence self-diagnosing instead of requiring reconstruction.

## Test Plan

- `pytest tests/test_claude_native_bridge.py -k wait_for_claude_prompt_ready`
  — 3 passed (the pre-existing crash-tail test plus the two added below).
- Full file: 152 passed; the 3 failing tests are pre-existing MCP
  channel-server tests unrelated to this change (verified by reproducing
  them on the stashed clean tree).
- `pre-commit run --files omnigent/claude_native_bridge.py tests/test_claude_native_bridge.py`
  — clean (ruff-format normalized one line).

## Type of change

- [x] Bug fix
- [ ] Feature
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Two regression tests added: one asserts the empty-capture count appears
in the error and no bogus "Last terminal output" tail is attached when
every capture was empty; the other proves the tail comes from an in-loop
capture and that a box-present frame arriving only after the deadline
never leaks into the error (i.e. no post-deadline re-capture happens).
Manually verified the live behavior earlier in the investigation by
driving real `claude` 2.1.203 under the production 80x24 tmux geometry
(idle, a 6-subagent fan-out, pane shrunk to 8 rows, all permission
modes) to establish which frames the detector sees.
2026-07-08 13:45:12 -07:00
Sabhya Chhabria 18a2f025a0 refactor(web): redesign Appearance settings (Mode / Color theme / Terminal) (#2225)
Reorganize the Appearance page so its two orthogonal choices read
clearly. The single "Theme" block is split into labeled subsections —
"Mode" (System / Light / Dark) and "Color theme" — each with a one-line
helper; "Terminal theme" stays its own section.

- Mode cards now show a mini app-window preview (light / dark, and a
  diagonally split tile for System) instead of a bare icon.
- Color theme moves into a dropdown (shadcn Select) with a swatch chip
  per option; the trigger mirrors the current selection.
- One selection treatment across the card groups: accent border + a
  corner checkmark badge, via a shared keyboard-navigable radiogroup
  (roving tabindex + arrow keys). focus-visible stays distinct from
  selected, and each group is labeled via aria-labelledby off its heading.

No available options or their names change — only organization, layout,
and interaction consistency. Unit tests + the Appearance e2e are updated.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-08 13:35:56 -07:00
jtaylorisbell 37913e67a2 fix(host): forward DATABRICKS_AUTH_STORAGE to spawned runners (#2132)
_RUNNER_ENV_ALLOWLIST forwards DATABRICKS_CONFIG_PROFILE and
DATABRICKS_CONFIG_FILE but not DATABRICKS_AUTH_STORAGE. The host daemon
inherits it (cli.py adds the DATABRICKS_ prefix to the daemon env), so
when the token store is selected via that env var (e.g. the plaintext
JSON cache while ~/.databrickscfg [__settings__] auth_storage=secure) the
host authenticates but every spawned runner falls back to the cfg
default, reads a different/stale token store, and the runner tunnel is
rejected with HTTP 401 even though the host is online.

Add DATABRICKS_AUTH_STORAGE to the allowlist -- a non-secret storage
backend selector, same rationale as the adjacent config selectors -- so
host and runner resolve the same credential store. Deliberately not
switching the runner to the daemon's blanket DATABRICKS_ prefix, which
would leak bearer secrets into (possibly hosted) runners.

Co-authored-by: Isaac

Co-authored-by: jtaylorisbell <jtaylorisbell@users.noreply.github.com>
2026-07-08 13:07:52 -07:00
Sabhya Chhabria 49eb088544 feat(web): add a color-theme picker with popular palettes (#2147)
Adds a color-palette axis to Appearance settings, independent of the
light/dark mode. Ships Omnigent (brand pink, default) plus four popular
palettes — Dracula, GitHub, Catppuccin, and Gruvbox — each with full
light + dark variants.

A palette re-points the existing CSS custom properties under a
`data-theme` attribute on <html>, so it composes with next-themes'
`.dark` class and re-skins the whole app without any component change.
The choice persists in localStorage and is applied before first paint
(no flash). Text selection now tracks the palette accent instead of a
hardcoded pink.

Covered by a themePalette unit suite, SettingsPage picker assertions,
and a Playwright e2e test for the Appearance palette picker.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-08 12:31:22 -07:00
Aravind Segu 8ab45a8256 chore(db): drop unused list_conversations_by_host_id + its index (#2221)
`list_conversations_by_host_id` had no production callers. Its docstring
claimed reconnect reconciliation used it, but the server mounts the host
tunnel without an `on_host_connect` callback, so that path is never
wired; the real reconnect/relaunch flow keys off `runner_id` via
`list_conversations_by_runner_id`.

Remove the store method (interface + SQLAlchemy impl) and the
`ix_conversations_host_id` index that existed solely to serve it.
`conversations.host_id` carries no FK, so nothing else depends on the
index. Add migration z1a2b3c4d5e6 to drop it.

Drop the two dedicated store unit tests and the
`test_reconnect_with_dead_runner_triggers_relaunch` integration test
(its synthetic callback was the only other caller, exercising the
never-wired host-id reconciliation path). Flip the migration test to
assert the index is absent at head.

Co-authored-by: Isaac
2026-07-08 12:00:02 -07:00
Aravind Segu 20ccef117f feat(db): add conversation_id to conversation_items primary key (#2212)
Widen the conversation_items primary key from (workspace_id, id) to
(workspace_id, conversation_id, id) so a conversation's items stay
contiguous under the workspace prefix for the per-conversation prefix
scans that dominate item reads.

Co-authored-by: Isaac
2026-07-08 11:16:44 -07:00
Pat Sukprasert aa53f689df fix(deps): drop mlflow from dev extras (accidentally added by #526) (#2207)
* fix(deps): drop mlflow from dev extras (accidentally added by #526)

mlflow was not in the dev deps on main before #526 merged. It was
inadvertently introduced via a conflict resolution that carried over a
stale comment block from the PR branch. Remove it and clean up the
now-orphaned comment fragment in the hindsight-client entry.

* chore(oss): regenerate public lockfiles against public PyPI/npm

* fix(deps): rename hindsight extra to memory (omnigent[memory])

The design steer on #526 asked for omnigent[memory] (capability-named,
not vendor-named) but the PR landed with omnigent[hindsight]. Rename
the extra key and update all user-facing references: the install hint in
the error message, the remy example, and the module docstring. Internal
names (hindsight.py, HindsightRetainTool, hindsight_retain tool names,
hindsight-client package) are unchanged.

* chore(oss): regenerate public lockfiles against public PyPI/npm

* chore: revert web/package-lock.json to main

The OSS lockfile-regen bot bumped prettier 3.8.4 -> 3.9.4 in
web/package-lock.json on this branch. Prettier 3.9 reformats multi-line
type unions, marking many untouched .ts files dirty and failing the
web-prettier gate. This PR only changes pyproject.toml + Python, so the
web lockfile should match main. Reverting drops the unrelated prettier
bump and its formatting churn.

---------

Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-07-08 16:03:29 +00:00
Ben 0122b7f292 feat(tools): add Hindsight long-term memory built-in tools (#526)
* feat(tools): add Hindsight long-term memory built-in tools

Adds three first-party built-in tools — hindsight_retain / hindsight_recall /
hindsight_reflect — backed by Hindsight (https://github.com/vectorize-io/hindsight),
an open-source agent-memory system. Resolves issue #369.

- omnigent/tools/builtins/hindsight.py: Tool subclasses for retain/recall/reflect.
  The memory bank resolves from config.bank_id, else ctx.agent_id, else
  ctx.conversation_id, so a single declaration isolates memory per agent.
- Registry: lazy factories in builtins/__init__ that probe for hindsight-client
  and fail with an install hint (mirrors the modal sandbox _ensure_sdk pattern).
- Packaging: optional 'hindsight' extra (hindsight-client); kept in the dev set
  so the mocked tests can import it (same rationale as mlflow); mypy override.
- Manifests: registry frozenset lock + onboarding list_builtin_tools.
- Docs: tools.builtins example in AGENTSPEC.md.
- Example agent: examples/remy uses all three tools.
- Tests: tests/tools/builtins/test_hindsight.py (mocked client, no network).

hindsight-client is optional and lazily imported, so base installs are unaffected.

Signed-off-by: Ben <ben.bartholomew@vectorize.io>

* fix(tools): dispatch Hindsight memory builtins under wrapped harnesses

The registry entries alone only execute under the native llm executor. Under a
wrapped harness (claude-sdk / codex / cursor / pi) tool calls go through the
runner's local dispatcher, which only runs tools in _ALL_LOCAL_TOOLS — so
hindsight_retain/recall/reflect fell through to the harness and silently no-op'd.

Mirror the web_search wiring in omnigent/runner/tool_dispatch.py:
- add _HINDSIGHT_TOOLS to _ALL_LOCAL_TOOLS (runner dispatches them) and to
  _NATIVE_RELAY_BUILTIN_TOOLS (native harnesses have no memory of their own)
- add _execute_hindsight_tool / _hindsight_config_from_spec: read the builtin's
  spec config, build the tool, invoke with a ToolContext carrying agent_id so
  the bank resolves correctly
- tests/runner/test_hindsight_local_dispatch.py covers dispatch + bank resolution

Full tests/runner suite green (927 passed).

Signed-off-by: Ben <ben.bartholomew@vectorize.io>

* docs(examples): pin a stable bank_id in the remy example

Memory now lands in a human-readable bank ('remy') instead of the opaque agent
id, so it's easy to find in Hindsight. A comment notes that omitting bank_id
falls back to per-agent isolation.

Signed-off-by: Ben <ben.bartholomew@vectorize.io>

* docs(tools): make Hindsight memory tools prompt the model to actually call them

Models tend to acknowledge a fact in chat without persisting it. Two levers:
- Tool descriptions (shown to every agent that enables the tools) now state that
  context is lost between sessions and spell out when to call retain/recall.
- examples/remy prompt now mandates calling hindsight_retain and forbids claiming
  a save without a successful tool call.
- AGENTSPEC notes that agent authors should prompt their agent to use the tools.

No behavior change to the tools themselves.

Signed-off-by: Ben <ben.bartholomew@vectorize.io>

* docs: drop AGENTSPEC.md edits from this PR

Leave the core spec doc untouched to keep the PR's review surface minimal — the
tools are documented via the examples/remy agent and the tool descriptions
instead.

Signed-off-by: Ben <ben.bartholomew@vectorize.io>

* chore(deps): regen uv.lock with hindsight-client and security fixes

Regenerates the lockfile to include hindsight-client 0.8.3 and its
transitive dependencies. Picks up cryptography 48.0.1 and
pydantic-settings 2.14.2 (fixes OSV advisories GHSA-537c-gmf6-5ccf
and GHSA-4xgf-cpjx-pc3j already present on main).

* test(remy): add structural e2e test for the Remy memory example

Satisfies the test_every_agent_has_a_dedicated_test_file coverage guard.
Checks name, harness, the three Hindsight builtins, and that they all
share bank_id 'remy'. Pure spec-load -- no credentials needed.

---------

Signed-off-by: Ben <ben.bartholomew@vectorize.io>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-08 14:58:48 +00:00
Daniel Lok c910c47a46 feat(db): index policies by a name checksum instead of the raw name (#2178)
The policies table enforced name uniqueness on the VARCHAR(256) name
column via a partial unique index (ix_policies_default_name, scope=default)
and a composite unique constraint ((session_id, name)). Both are now keyed
on a new name_cksum column holding sha256(name) — a fixed 32-byte digest —
so the index entries are compact and fixed-width instead of a wide varchar.

Uniqueness semantics are unchanged: two names collide iff their digests do.
The checksum is stamped on INSERT by an ORM column default and recomputed by
the store on rename; it stays store-internal and never appears in the Policy
entity or the HTTP/SDK schema. SQLite has no sha256(), so the migration
back-fills the digest in Python.

Co-authored-by: Isaac
2026-07-08 21:04:32 +08:00
Daniel Lok ab7002cb9c Revert "feat(benchmarks): HTTP user-journey performance harness (seeded corpu…" (#2200)
This reverts commit 7572d965a0.
2026-07-08 13:03:01 +00:00
Daniel Lok 7572d965a0 feat(benchmarks): HTTP user-journey performance harness (seeded corpus + backend matrix) (#2159)
* feat(benchmarks): add HTTP user-journey performance harness

Add a runnable benchmark under dev/benchmarks/omnigent/ that boots a real
omnigent server against a throwaway SQLite DB (no runner, no LLM), drives key
HTTP journeys under load, and emits a versioned JSON report of latency
percentiles + throughput. Modeled on MLflow's dev/benchmarks/gateway workflow.

v1 covers the server + DB request path: list_sessions, create_session,
get_session, and load_conversation_history (history seeded runner-free via the
external_conversation_item event). The report JSON is the contract a workspace
Databricks notebook consumes (artifact -> Delta -> AI/BI dashboard).

The environment is written as a superset: a with_runner flag (default off)
gates a mock-LLM + runner path so phase-2 full-turn journeys are additive, not
a rewrite.

Co-authored-by: Isaac

* feat(benchmarks): seeded corpus, backend matrix, nightly workflow

Make the benchmark meaningful and automated:

- seed.py: deterministic corpus seeder via the store API (no HTTP/runner) —
  create_session_with_agent + "local" permission grant + batched append.
  Idempotent (reuse marker), --reseed to force, SEED_SCHEMA_REVISION pinned
  to the Alembic head.
- environment.py / run.py: accept --database-uri and stamp a `backend`
  (sqlite/postgres) field into the report. None keeps the throwaway-SQLite
  path; a seeded URI (SQLite file or postgresql+psycopg://) benchmarks a
  realistic corpus.
- journeys.py: read journeys target an existing corpus session (self-seed
  fallback when empty); add search_sessions (the unindexed LIKE path where
  SQLite and Postgres diverge most).
- Schema-drift guard: scripts/check_benchmark_seed_schema.py + a pre-commit
  hook fail when the DB schema head moves without the seed being refreshed.
- benchmark.yml: nightly + dispatch, backend matrix (sqlite + a postgres:16
  service container), per-backend seed with an schema-keyed SQLite seed cache,
  one artifact per backend.

Verified: seeded SQLite e2e shows list_sessions ~1.3ms -> ~6ms p50 and
search_sessions ~79ms p50 vs the empty-DB baseline. 9 smoke tests pass; ruff,
mypy, and pre-commit (incl. the new guard) clean. The Postgres leg's live run
is first exercised by CI (Docker is org-locked locally); the psycopg dialect
resolves and the URI passthrough is covered by the SQLite --database-uri path.

Co-authored-by: Isaac

* feat(benchmarks): full-turn (runner) journeys

Add four full-turn journeys that drive a real agent turn end-to-end through the
runner + a zero-latency mock LLM (with_runner=True), all using the openai-agents
SDK harness:

- session_cold_start: fresh session provisioning + first turn (runner spawn +
  executor construction).
- warm_turn: steady-state per-turn dispatch overhead.
- time_to_first_token: post → first streamed output_text delta (subscribes the
  session SSE stream; waits for connect rather than a fixed sleep so the delay
  isn't in the measured window).
- interrupt: cancel a running (gated) turn; time to the cancellation marker.

Only measure what we control: full-turn journeys always use openai-agents, which
runs in-process (no vendor binary) — native harnesses launch the real CLI and
are excluded. The mock is zero-latency, so numbers are omnigent
dispatch/streaming/cancel overhead, not model latency. No delay knob added.
Excluded as agent-dependent: multi-turn, tool-calling, large-history turns.

run.py auto-boots with_runner=True when any selected journey needs it and stamps
harness=openai-agents. Adds a needs_runner flag on Journey; adds async
time_to_first_delta / drive_and_interrupt / _wait_idle to BenchEnvironment.
Extends the mock's /mock/set_fallback with an optional stream flag so a
reset-surviving fallback can emit deltas (needed for TTFT).

Verified: a with_runner smoke runs all four journeys once (first end-to-end
exercise of the runner path); manual e2e shows warm_turn ~235ms vs
session_cold_start ~1.6s. 10 smoke tests pass; ruff, mypy, pre-commit clean.

Co-authored-by: Isaac
2026-07-08 20:23:25 +08:00
Tomu Hirata e52e938e4c feat(ui): allow users to edit policy name when adding a policy (#2196)
Pre-fill the name field with the auto-derived slug and let users
override it. Also fix parameter description overflow in the dialog
with min-w-0 on the content container and break-all on long text.
2026-07-08 21:17:52 +09:00
Daniel Lok 78048a3ab2 docs(doc-drafter): teach the drafter to delete docs for removed features (#2198)
The doc-drafter prompt was framed purely additively (extend a page, create
a page, document what the PR "introduced"), so a PR that removes or
deprecates a user-facing feature would nudge the drafter toward writing
prose rather than pruning the now-untrue docs. The classifier already
routes removals correctly, so the gap was only in the drafter.

Add a removal/deprecation path: classify the diff intent in Step 1, and in
Step 3 delete whole pages (git rm + drop the SECTIONS sidebar entry) or cut
sections/references for a removed feature, or mark deprecated-but-present
features in the site's usual style. Report deletions in the output summary.

The workflow already stages and detects deletions (git add -A /
git status --porcelain), so no workflow change is needed.

Co-authored-by: Isaac
2026-07-08 12:00:02 +00:00
Serena Ruan e35593ffa9 fix(web): serialize background flush behind the foreground send chain (#2175)
Queued messages could reach the runner out of FIFO order when the user
navigated away mid-queue. The foreground flush (maybeFlushQueuedHead →
send()) serializes its POSTs on the module-level sendChain, but the
background flush (flushBackgroundQueues → postEvent) bypassed it. At the
navigate-away handoff, an in-flight foreground send() still awaiting its
chain slot could be overtaken by a background postEvent that fired
immediately — delivering messages out of submission order (observed on
cursor-native, whose instant turns make the window easy to hit; the runner
appends FIFO as received, so the scramble is entirely client-side).

Have flushBackgroundQueues join the same sendChain: take a slot (await
priorSend before the upload/post, release in finally), so every POST across
both paths is ordered through one primitive.

Also reset sendChain in initChatStore so a prior run's unresolved send
can't block the next (production calls it once at boot; tests per case),
and restore the real send action in the test beforeEach (a prior test's
setState({ send: spy }) otherwise leaks into later cases).

Test: a background flush fired while a foreground send()'s POST is held
open does not deliver until the foreground POST resolves. Verified it fails
without the fix (background overtakes) and passes with it.

Co-authored-by: Isaac
2026-07-08 19:48:18 +08:00
Tomu Hirata 8810963c90 fix(tests): make test_interrupt_forwards_to_harness_before_cancelling deterministic (#2194)
Replace a timing-based 0.5 s wait_for/shield assertion with a
fwd_seen Event set by _ForwardBlockingHarnessClient.post() the
moment the interrupt forward blocks on fwd_gate. The test now
waits for provable in-flight status instead of hoping 0.5 s is
long enough on a loaded CI machine.
2026-07-08 11:11:19 +00:00
Serena Ruan 1aca7bc9e5 feat(web): split sidebar sessions into My sessions / Shared with me tabs (#2156)
* feat(web): split sidebar sessions into My sessions / Shared with me tabs

Sessions shared with the viewer previously sat in an inline collapsible
"Shared with me" section below the owned-session list. Move them to a
dedicated tab so the two scopes are visually distinct and the shared list
gets its own space (flat, headerless, with its own infinite scroll).

The "My sessions" tab keeps the full Pinned / Projects / Sessions
structure; "Shared with me" is a flat list of every non-archived session
the viewer doesn't own (computed from notArchived, so a pinned/filed
shared session never drops off it). New session snaps back to My sessions.

The tab strip only renders on a multi-user server — gated on
!isCurrentServerLocal(), the same predicate AppShell uses to disable the
Share affordance. A loopback-only local server has a single user and
can't share sessions, so the split is meaningless there; the list falls
back to the owned sessions. Keyboard nav and shift-select are tab-aware
and, on the shared tab, ignore the collapsed set (the list always renders
expanded), so a stale persisted "Shared with me" collapse can't empty them.

Co-authored-by: Isaac

* fix(web): keep pinned/filed shared sessions off My sessions; paginate empty tabs

Address two issues in the sidebar tab split:

- Pinned and project folders drew from all non-archived sessions, so a
  shared session the viewer pinned (localStorage is ownership-agnostic) or
  filed into a project (editable share) rendered under Pinned / a project
  folder on My sessions AND on the Shared tab. Build both from owned-only
  sessions so non-owned sessions stay on the Shared tab exclusively.

- The list is one paginated stream (owned + shared mixed, updated_at desc),
  so a tab can be empty on the loaded window while its sessions live on a
  later page. The pagination sentinel lived inside the non-empty render
  branch, so an empty tab stopped fetching and stranded the user on a false
  "empty" state (e.g. Shared tab when page 1 is all owned). Keep the
  sentinel mounted in the empty branch when more pages exist.

Co-authored-by: Isaac

* refactor(web): reuse Pinned / Projects / Sessions layout for both sidebar tabs

Rather than rendering the Shared tab as a bespoke flat list, scope the
section-building to the active tab's conversations and render the same
Pinned / Projects / Sessions tree for both tabs. "mine" is the sessions
the viewer owns; "shared" is the ones others shared with them.

- Pins are localStorage and ownership-agnostic, so a pinned shared session
  now floats to a Pinned section on the Shared tab, matching My sessions.
- Projects stay a My-sessions-only tool: filing into a project is now
  gated on ownership (the row's "Add to project" / "Move session" menu
  item is hidden for non-owned sessions), and the Shared tab renders no
  Projects group. A shared session that already carries a project label
  just lands in the flat Sessions list there.
- Collapses the special-case `showShared` render branch and the shared
  special cases in keyboard-nav / shift-select ordering, since `sections`
  is now tab-scoped.

Co-authored-by: Isaac
2026-07-08 18:11:33 +08:00
Tomu Hirata d63ca5dfda feat: change conversations.title from Text to VARCHAR(768) (#2182)
Fixes two MySQL incompatibilities: TEXT columns cannot have DEFAULT values,
and TEXT columns cannot be indexed without a key-prefix length.

- db_models.py: title → String(768); ix_conversations_parent_title_unique
  gains mysql_length={"title": 512} so the index works on MySQL
- Migration w1a2b3c4d5e6: alters the column and drop/recreates the unique
  index with the MySQL prefix hint; handles the case where the index is
  absent on MySQL (TEXT was never indexable there)
- Tests: 4 new tests covering VARCHAR(768) column type, server_default,
  data survival, and downgrade round-trip on SQLite; manually verified
  upgrade+downgrade on PostgreSQL and MySQL
2026-07-08 10:10:57 +00:00
Pat Sukprasert 5196f8cfb5 revert: back out codex-native --model launch flag + restart-with-model dialog (#1279) (#2185)
Reverts PR #1279. Model selection can now be done right after fork as a
first action for codex, so the dedicated codex-native --model launch flag
and the "Restart with model…" fork dialog are no longer needed.

Backs out:
- Backend: the OMNIGENT_CODEX_NATIVE_MODEL_FLAG opt-in flag, the
  codex --help --model capability probe, and the explicit --model launch
  plumbing in codex_native_app_server.py; the fork route's model_override
  parameter, validation, and family-check (_agent_harness_id); the
  SessionForkRequest.model_override schema field and its store plumbing.
- Frontend: the codex-only RestartWithModelDialog and the AgentInfo
  "Restart with model…" trigger; forkSession's modelOverride param.
- The associated backend, store, vitest, and e2e-ui tests.

The always-on per-session config.toml `model =` pin and the pre-existing
session-level model_override field are untouched.

Resolved conflicts from the ap-web -> web frontend rename and later
main-branch changes to AgentInfo by re-applying the removal surgically on
top of current main rather than adopting the stale pre-PR text.

Verified: 202 backend tests (fork route, conversation store,
codex_native_app_server), 34 AgentInfo vitest, web tsc, and prettier all pass.

Co-authored-by: Isaac
2026-07-08 16:56:37 +07:00
Serena Ruan 235a4eafb3 fix(web): let Cancel step back to the policy list in the add-policy dialog (#2183)
Landing on a policy's config view in the add-policy dialog (the "+" in the
agent info popover, and the admin global-policies page) left no way back to
the policy list: both Cancel and the X closed the whole modal. Selecting the
wrong policy meant reopening the dialog from scratch.

Cancel now deselects back to the list when a policy is selected, and only
closes the dialog from the list itself. Closing via X/Escape resets the
selection so reopening always starts at the list instead of a stale config
view.

Co-authored-by: Isaac
2026-07-08 17:11:33 +08:00
Tomu Hirata 20bb6c7469 feat(android): add ktlint formatter to CI (#2179)
* feat(android): add ktlint formatter to CI and pre-commit

Kotlin files had no enforced style — add ktlint 1.8.0 to close that gap,
mirroring the pattern already used for Swift (local wrapper that no-ops
when the tool is absent) but with full CI enforcement since Java is
available on ubuntu-latest.

Changes:
- web/android/.editorconfig: ktlint style config (4-space indent,
  100-char line length, standard rule set)
- web/android/bin/ktlint.sh: wrapper script; exits 0 if ktlint is not
  installed so developers without it don't get blocked at commit time
- .pre-commit-config.yaml: android-ktlint-format (auto-fix) and
  android-ktlint-check (lint gate) hooks for *.kt / *.kts files
- .github/workflows/lint.yml: installs ktlint before pre-commit runs so
  the check is enforced in CI
- web/android/**/*.kt: apply initial ktlint --format pass to existing
  sources so the hook is green from the first run

* fix(android/ci): harden ktlint install step and scope editorconfig

Address review feedback on #2179:

- Add `curl --fail` so a 4xx/5xx response (e.g. wrong version tag) fails
  loudly at the download step rather than silently installing an HTML body
- Verify the ktlint binary against the SHA-256 checksum published alongside
  each release before marking it executable
- Add `root = true` to web/android/.editorconfig so a future repo-root
  .editorconfig can't bleed Kotlin-unintended settings through EditorConfig
  inheritance
2026-07-08 09:06:46 +00:00
Tomu Hirata d1c418bd40 feat(db): change hosts table primary key to (workspace_id, host_id) (#2165)
Promotes host_id into the PK alongside workspace_id, demoting owner and
name to regular NOT NULL columns backed by a uq_hosts_workspace_owner_name
unique constraint. The old uq_hosts_host_id unique constraint is dropped
since uniqueness is now enforced by the PK.

- Migration u1a2b3c4d5e6: uses batch_alter_table with copy_from to
  correctly rebuild the SQLite table from scratch with the new PK.
- HostStore.upsert_on_connect: primary lookup now keys on (workspace_id,
  host_id). The W2-class boundary (reject foreign-owner host_id claim)
  is enforced explicitly via IntegrityError when allow_host_id_reown=False
  and the existing row's owner doesn't match the connecting owner.
- _rotate_host_id: already correct; kept as-is.
- Tests: update session.get() PK tuple in test_db_models; fix
  test_unique_host_id to commit h1 before adding h2 so the PK violation
  fires at the DB; update test_migration_workspace_id to handle the later
  PK override for hosts; add test_migration_host_pk_workspace_host_id.
2026-07-08 17:09:36 +09:00
Serena Ruan 8fffc13560 fix(web): keep queued messages FIFO when status flickers idle (#2167)
* fix(web): keep queued messages FIFO when status flickers idle

A follow-up sent while an earlier one waits in the client-side queue could
jump ahead of it: handleSend takes the direct send() path whenever the
session reads idle, and that path isn't ordered against the queue drain.
On harnesses whose sessionStatus flickers idle between quick turns
(cursor-native), a later message slipped onto the direct path mid-queue
and was delivered before the still-queued earlier one — scrambling the
order the agent received (verified in a runner log: the runner appended
messages FIFO as they arrived; the reorder happened client-side).

Funnel every send through the single FIFO queue once the conversation has
anything queued, even if it momentarily reads idle. enqueueMessage already
flushes immediately when genuinely idle, so this never stalls a message —
it only prevents the direct path from overtaking the queue.

Co-authored-by: Isaac

* test(web): unit-test the queue-vs-send decision

Extract handleSend's enqueue-vs-direct-send predicate into an exported
pure helper, shouldQueueSend, and unit-test it. The decision was inline in
handleSend (which reads the store) and had no coverage; the ordering fix
lives entirely in this predicate.

Tests: new chat sends directly; busy (streaming/running/waiting) queues;
idle with an empty queue sends directly; idle but with this conversation
already queued still queues (the ordering-race fix); a different
conversation's queue doesn't force this one onto the queue.

Co-authored-by: Isaac

* docs(web): trim shouldQueueSend comments

Co-authored-by: Isaac
2026-07-08 15:03:31 +08:00
Daniel Lok 2f59c89271 feat(db): store enum-like columns as SMALLINT int codes (#2090)
The low-cardinality closed-set columns (conversations.kind,
conversation_items.type/status, comments.status, account_tokens.kind,
policies.type, policies.scope, hosts.status, agents.kind) were stored as
VARCHAR guarded by string CHECK constraints. Store them as compact
SMALLINT integer codes instead, matching the existing int-coded
session_permissions.level.

A new omnigent/db/enum_codecs.py owns the stable name<->int tables and is
the single translation point: conversion happens only at the store
row<->entity boundary, so entities, the HTTP API, the web client, and the
SDKs keep seeing the string names unchanged. A backfill migration
(u1a2b3c4d5e6) converts existing rows in place and is reversible, portable
across SQLite and PostgreSQL. The agents.kind and policies.scope partial
indexes are dropped and recreated around the column swap since SQLite
batch mode can't copy a partial-index predicate across a rename.

The comment-update route now rejects an unknown status with a 400 instead
of letting the enum codec raise into an opaque 500 — the column is now a
closed enum (draft/addressed), matching the validation the update_comment
tool already enforced.

Co-authored-by: Isaac
2026-07-08 14:25:08 +08:00
Serena Ruan 127331884e fix(host): show session id in runner launch log (#2170)
Include the conversation id in host launch frames so foreground host logs can point runner starts back to the owning session.
2026-07-08 14:21:23 +08:00
Joel Robin P e689084e8e fix(web): truncate long emails in the share dialog instead of overflowing it (#2108)
* fix(web): truncate long emails in the share dialog instead of overflowing it

Signed-off-by: joelrobin18 <joelrobin1818@gmail.com>

* Fix first part

Signed-off-by: joelrobin18 <joelrobin1818@gmail.com>

---------

Signed-off-by: joelrobin18 <joelrobin1818@gmail.com>
2026-07-08 14:20:24 +08:00
Zeyi (Rice) Fan c3af15235b feat(terminals): native "+ New shell" honors $SHELL and offers installed shells (#2166)
## Related issue

N/A

## Summary

- Native-harness sessions (`omnigent claude`/`codex`/`pi`/etc.) previously
  always opened bash for "+ New shell"; they now open the user's login shell.
- `omnigent/_platform.py`: add `default_interactive_shell()` (basename of
  `$SHELL` when it names a known shell on PATH, else bash) and
  `installed_interactive_shells()` (that default first, then any of
  bash/zsh/fish on PATH; always non-empty).
- `omnigent/native_coding_agents.py`: `native_shell_terminal_spec()` now
  declares one unsandboxed caller-process terminal per installed shell, keyed
  and commanded by the shell basename, `$SHELL` first. The 11 native wrappers
  call this shared helper instead of a hardcoded `{"shell": {"command": "bash"}}`
  block.
- `web/src/shell/NewTerminalButton.tsx`: branch on
  `useTerminalFirst().isNativeWrapper` — native sessions with multiple shells
  get a split button (primary click launches the `$SHELL` default; a caret opens
  a picker of installed shells, default labeled). SDK agents with multiple
  distinct-purpose terminals keep the existing plain dropdown unchanged.
- `examples/polly/config.yaml`: add a `zsh` terminal alongside the existing
  bash `shell` for the builtin polly agent.

## Test Plan

- `uv run pytest tests/inner/test_proc_and_platform.py tests/test_native_coding_agents.py`
  — new unit tests for shell detection and the multi-shell spec.
- `uv run pytest -k "native and (materialize or terminal or agent_spec)"` — 296
  passed, including the runner create-session-terminal flow; updated 4 native
  wrapper tests that asserted the old single-`shell` shape.
- `npx vitest run src/shell/NewTerminalButton.test.tsx` (+ related shell suites)
  — split-button default launch, caret pick of a non-default shell, and SDK
  dropdown-unchanged cases.
- ruff check/format, prettier, oxlint, and tsc clean on all touched files.
- Verified polly's YAML parses through `_parse_terminals` with both `shell`
  (bash) and `zsh` terminals.

## Type of change

- [ ] Bug fix
- [x] Feature
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Shell detection, the native multi-shell spec, and the frontend split-button
behavior are covered by new/updated unit tests (pytest + vitest). Manually
verified that `default_interactive_shell()`/`installed_interactive_shells()`
resolve the host's shells, all 11 native wrappers import cycle-free, and
polly's edited YAML parses through Omnigent's real terminal parser. The live
end-to-end (clicking "+ New shell" in a running native session and confirming
the shell that opens) was not exercised here as it needs an interactive session.
2026-07-08 05:32:05 +00:00
Serena Ruan 5c580d3fae feat(web): show and manage the branch when starting in an existing worktree (#2098)
* feat(web): show and manage the branch when starting in an existing worktree

Starting a session directly in a pre-existing git worktree previously
bound the workspace with no branch recorded, so the sidebar showed no
branch subtitle and the opt-in "Delete local branch" flow was
unavailable — the same worktree Omnigent would offer to clean up if it
had created it.

Thread the existing worktree's branch through as a new `workspace_branch`
field on both create paths (`POST /v1/sessions` and
`POST /v1/hosts/{id}/runners`). It persists as the session's `git_branch`
without creating a worktree, so the sidebar shows the branch and the
existing delete dialog (gated on `git_branch != null`) can remove the
worktree + branch. `workspace_branch` is mutually exclusive with `git`
(which creates a worktree) and requires a host; the server validates the
branch name since the host runs no git for this path.

Co-authored-by: Isaac

* test(e2e-ui): assert workspace_branch is sent for existing worktrees

The E2E UI Required judge flagged the existing-worktree start-session
change as needing Playwright coverage. Extend the existing
select-existing-worktree e2e_ui test to assert the create body now
carries workspace_branch (the picked worktree's branch), alongside the
existing no-git-spec / worktree-dir-workspace assertions.

Co-authored-by: Isaac

* fix(server): don't force-remove an existing worktree on create-rollback

The create-rollback in `_create_session_from_existing_agent` runs
`git worktree remove --force` + `git branch -D` when
`create_conversation` fails, to clean up an orphan worktree Omnigent
just created. It was gated on `git_branch is not None`.

The existing-worktree path (`workspace_branch`) also sets `git_branch`
but creates no worktree — the workspace IS the user's pre-existing
worktree. So a persistence failure on that path would force-remove the
user's worktree and delete their branch: data loss.

Gate the rollback on whether Omnigent actually created a worktree here
(new `created_worktree_path`), mirroring the `worktree is not None`
guard already used on the launch-runner path in hosts.py. Add two
integration tests: a failure on the workspace_branch path sends no
remove frame, and a failure on the git path still rolls back the
worktree Omnigent created.

Co-authored-by: Isaac

* refactor(server): fold existing-worktree bind into SessionGitOptions

Replace the separate top-level workspace_branch field with an
existing_worktree flag on SessionGitOptions, so the git block carries
both modes: create (default) makes a worktree, bind
(existing_worktree=true) records a pre-existing worktree's branch as
git_branch without creating one. base_branch is rejected in bind mode.

This keeps a single branch-name concept and puts the create/bind intent
on the git object itself. The create-rollback stays gated on whether
Omnigent actually created a worktree (created_worktree_path in
sessions.py, the worktree object in hosts.py), so a bind-mode
persistence failure still never force-removes the user's worktree.

Behaviour is unchanged; only the wire shape moves from
{workspace_branch: "x"} to {git: {branch_name: "x", existing_worktree: true}}.

Co-authored-by: Isaac

* refactor(server): dedupe branch validation across worktree modes

Fold the create/bind split into a single `if body.git is not None`
block on both worktree paths and hoist the shared
`validate_branch_name` call above the mode branch, so the name is
validated once instead of in each arm. Behaviour is unchanged; create
mode still creates a worktree and bind mode still records the branch
without creating one.

Co-authored-by: Isaac
2026-07-08 12:50:24 +08:00
Tomu Hirata 0f1114d1bd feat(#900): shrink hosts.name from VARCHAR(256) to VARCHAR(64) (#2164)
Host names are short identifiers from config.yaml; 64 chars matches every
other short-identifier column in the schema. Adds migration t1a2b3c4d5e6
with upgrade/downgrade and a test verifying the column width after both.
2026-07-08 04:43:17 +00:00
Tomu Hirata 23ffb4b563 feat: make conversations.title NOT NULL, storing '' for untitled (#2158)
Back-fills NULL titles to '' via migration s1a2b3c4d5e6 and alters the
column to NOT NULL with a server_default of ''. The store layer converts
'' ↔ None at the entity boundary so the Conversation.title field stays
str | None throughout the application layer.
2026-07-08 04:22:06 +00:00
Pat Sukprasert c52dc80ca6 fix(openshell): use /sandbox as sandbox home to satisfy Landlock LSM (#2106) 2026-07-08 03:41:10 +00:00
Pat Sukprasert c22b17581f fix(host): non-editable install in host image to satisfy Landlock LSM (#2107) 2026-07-08 11:15:57 +08:00
Pat Sukprasert d447addcbc feat(harness-bench): observe native Tool calling + Policy DENY (#2096)
* feat(server): publish response.policy_denied on a native tool-call DENY

A native harness (Claude Code, Codex, ...) routes each tool call through
Omnigent's policy engine via the vendor PreToolUse hook
(POST /v1/sessions/{id}/policies/evaluate). The DENY verdict is returned
synchronously to that hook, so unlike the SDK/wrap path nothing on the session
stream reflects that a native action was blocked -- observers could only infer
it from the blocked tool's absence.

Publish a positive signal instead:
- New PolicyDeniedEvent (type "response.policy_denied", fields conversation_id/
  reason/phase) added to the ServerStreamEvent union. The wire name is
  response-prefixed to match the web-UI wire decoder, which matches the raw
  event: name literally (a bare "policy_denied" would be dropped).
- _publish_policy_denied helper mirrors _publish_collaboration_mode.
- Emitted from evaluate_policy on a tool_call-phase DENY, a sibling to the
  existing request-phase blocked-notice forward. Observational (not gated on
  write access); purely additive -- the synchronous hook response is untouched.

The web UI already handles this event type; the harness capability bench will
consume it to give native harnesses a real Policy DENY verdict.

Tests: PolicyDeniedEvent round-trips the union; the helper emits a typed,
union-valid event; _format_sse emits the response.policy_denied wire name.

* feat(harness-bench): observe native Tool calling + Policy DENY

The native-tui driver stubbed run_tool_turn, so every native harness row showed
`·` for Tool calling and Policy DENY -- a bench observation gap, not a native
limitation. Implement real observation:

- Tool calling (deny=False): post a per-vendor tool-provoking prompt (echo via
  the vendor's own shell tool), then scan session items for the new
  function_call the vendor bridge mirrors -> result.tool_calls.
- Policy DENY (deny=True): attach a tool_call-phase deny to the session via
  POST /v1/sessions/{id}/policies using the registered cel_policy handler
  (ternary expression targeting the provoked tool), then watch the stream for
  the response.policy_denied signal -> result.tool_call_denied. Does not rely on
  a blocked function_call_output (a native deny short-circuits at the hook and
  may persist no output), which is why the server-side positive signal exists.

Per-vendor tool name + prompt live on NativeVendor (Bash for claude/pi, shell
for codex); a native with no mapping SKIPs. SKIP (never a false UNSUPPORTED) on:
no tool mapping, fail-open policy (policy_hook_disabled_reason captured at
terminal-ensure), or the CEL handler being unregistered (cel_expr_python absent).

The transport-agnostic probes are unchanged -- they read result.tool_calls /
tool_call_denied. Manifest keeps tool_calling/policy_deny SUPPORTED (now
live-probed on both transports; env gaps reconcile as SKIPPED).

Tests: offline driver tests with a fake client/stream cover tool-call
observation, the deny attach + denied-event, and every SKIP path; the probes
turn the native results into SUPPORTED verdicts.

* fix(harness-bench): check tool_call_denied before the no-tool-call guard

The policy_deny probe was written for full-server, where a denied tool still
surfaces a function_call item. On native-tui a tool_call-phase DENY short-
circuits at the vendor PreToolUse hook *before* the tool runs, so no
function_call item persists and result.tool_calls is legitimately empty. The
probe's first guard (`if not tool_calls: SKIPPED`) therefore swallowed a real
native deny before ever checking tool_call_denied.

Hoist the tool_call_denied check to the top: a confirmed DENY (from the
response.policy_denied stream signal on native, or the blocked function_call_
output on full-server) is enforcement whether or not an item persisted. The
"model never attempted the tool" and "wrap-direct, no evaluation" SKIP branches
now only apply when no deny was observed. No full-server regression: a denied
full-server call still sets tool_call_denied and completes -> SUPPORTED.

* fix(harness-bench): deny any tool call by phase; vary deny-turn command

Two refinements from the first live run, where both natives skipped Policy DENY:

- codex ran the tool but the deny didn't fire: the CEL targeted
  event.data.name == "shell", but the wire tool_name in the policy-hook payload
  is the vendor's raw name, which need not equal the forwarder's item name.
  Deny on the phase alone (event.type == "tool_call") instead, so the block
  lands whatever the vendor calls the tool. That is exactly what "is a
  tool-call DENY enforced?" asks, and the bench-owned session makes a
  blanket tool-call deny harmless.
- claude called no tool on the deny turn: the deny turn reused the allow turn's
  session with an identical echo request, so the model saw it already done.
  Vary the echo token per turn (omnigent-bench-allow vs -deny) so the deny
  turn is a fresh request the model must actually call the tool to satisfy.

* docs(harness-bench): scope the manifest note to what is live vs wired

tool_calling is live-probed on both transports; policy_deny is live on
full-server and wired (but native enforcement is a follow-up) on native-tui.
Keep the note honest so a reader doesn't assume native DENY is confirmed.

* docs(harness-bench): record the root cause of unenforced native deny

Live diagnosis (temporary instrumentation, now removed) confirmed the native
Policy DENY gap: the deny policy IS attached to the correct session and the CEL
DENYs a tool_call event, but the tool runs anyway with NO policy evaluation on
the stream. Root cause: the bench's native terminal-ensure launch does not
thread ap_server_url into claude_native_bridge.build_hook_settings, so the
evaluate-policy PreToolUse hook (gated on `if ap_server_url:`) is silently
omitted -- no permission_hook.json is written and native tool calls are never
gated. Not a session-scoping issue (ruled out: policies=['bench_tool_deny'] on
the right session) and not a harness that ignores policy. Wiring the hook on the
bench launch path is the follow-up; the probe SKIPs cleanly meanwhile.

* feat(harness-bench): map tool provocation for every in-repo native

Extend _NATIVE_TOOL_PROVOCATION from 3 natives (claude/codex/pi) to all
in-repo ones: adds kiro (shell), qwen (run_shell_command), goose
(developer__shell), hermes (terminal), antigravity (run_command), kimi (Bash).
Tool names sourced from omnigent/policies/builtins/safety.py::ask_on_os_tools
and each vendor's native module, so each entry is a grounded claim, not a guess.

Now that the deny gates on the tool_call phase alone (name-agnostic),
``tool_name`` is only a descriptive non-empty gate, so a shared shell-tool
prompt covers the vendors uniformly. Comments/docstring updated to match (the
old "must equal the raw PreToolUse tool_name" note was stale). cursor-native is
deliberately left unmapped (lazy-chat; add once it provisions reliably), which
the skip test still relies on. SKIP-safety unchanged: a wrong prompt skips,
never a false verdict. Verification of the new entries is a live follow-up.
2026-07-08 02:51:00 +00:00
Sabhya Chhabria 91714a2219 feat(web): choose a terminal theme in Appearance settings (#2154)
* feat(web): add terminal theme preference module

A persisted light/dark palette choice for the terminal, independent of the app
chrome theme. Mirrors codeFontPreferences, localStorage-backed with an in-module
pub/sub so a Settings change re-themes mounted terminals live. "auto" follows the
app's resolved theme, while "light"/"dark" pin it.

* feat(web): choose a terminal theme in Appearance settings

Adds a Terminal theme radiogroup (Match app / Light / Dark) under Settings ->
Appearance. TerminalView resolves the chosen mode against the app theme and
pushes the result to the live xterm through the existing setTheme path, so a
light terminal can sit under a dark app and vice versa. The resolved palette is
exposed as data-terminal-theme on the terminal view for observability.

* test(e2e_ui): terminal theme is independent of the app theme

Drives the Appearance control and a live shell to assert a light terminal under
a dark app and a dark terminal under a light app, plus the match-app default and
persistence across reload.

* fix(e2e_ui): scope theme-toggle locators to the app Theme radiogroup

The new "Terminal theme" radiogroup shares the "Theme" substring and reuses the
Light/Dark radio labels, so test_theme_toggle's unscoped get_by_role locators
matched two elements under Playwright strict mode. Scope every lookup to the
exact app Theme radiogroup so the app-theme test stays unambiguous.
2026-07-07 18:35:33 -07:00
Aravind Segu 5b24dda378 feat(db): add workspace_id to all tables as leading primary-key column (#2138)
* feat(db): add workspace_id to all tables as leading primary-key column

Add a NOT NULL workspace_id column (BigInteger, server_default 0) to all
twelve tables and fold it into each primary key as the leading column,
laying the groundwork for per-workspace tenancy. Behaviour is unchanged:
every row lives in workspace 0 (DEFAULT_WORKSPACE_ID).

Migration r1a2b3c4d5e6 backfills existing rows to 0 and rebuilds each PK
to (workspace_id, <existing pk cols>) via SQLite-safe batch recreate /
explicit PK drop on PostgreSQL. Store and server primary-key lookups
(session.get) and dialect upserts (on_conflict index_elements) are
updated for the composite key.

Co-authored-by: Isaac
Signed-off-by: aravind-segu <aravind.segu@databricks.com>

* feat(db): scope all store queries to the default workspace_id

With workspace_id now the leading primary-key column, queries that
filtered only on the old key columns (e.g. WHERE id = ?, WHERE user_id
= ?, WHERE owner = ?) could no longer seek the primary-key index — the
unconstrained leading workspace_id degraded them to scans.

Add workspace_id == DEFAULT_WORKSPACE_ID to every store/server query on
these tables — selects, updates, deletes, subqueries, joins, the legacy
Query.filter paths, and the raw-SQL ILIKE search fallback — so
primary-key lookups seek the composite PK again and every access path is
workspace-scoped (forward-correct for multi-tenancy). Behaviour is
unchanged: all rows live in workspace 0.

Co-authored-by: Isaac
Signed-off-by: aravind-segu <aravind.segu@databricks.com>

* feat(db): resolve workspace_id through a context seam, not a constant

Introduce ``current_workspace_id()`` (a ContextVar defaulting to
DEFAULT_WORKSPACE_ID) plus a ``workspace_scope`` context manager, and
route every store/server access through it: reads and filters call
``current_workspace_id()`` instead of the hardcoded constant, and the
workspace_id column's insert default is now that callable (so ORM
inserts stamp the active workspace).

This is the single injection point a multi-tenant deployment needs.
OSS leaves the ContextVar at 0, so behaviour is unchanged; a deployment
like universe binds a real workspace id per request via ``workspace_scope``
in middleware — an additive change that touches none of these files, so
the code stays byte-identical across deployments and syncs cleanly.

Adds tests covering the default, scope set/reset, insert stamping, and
cross-workspace read isolation.

Co-authored-by: Isaac
Signed-off-by: aravind-segu <aravind.segu@databricks.com>

---------

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-07 17:10:10 -07:00
Roy Reznik 42177d0e39 feat(auth): opt-in flag to skip OIDC email_verified check (#1859)
Standard Okta tiers (without custom API Access Management) omit the
email_verified claim from id_tokens for directory-provisioned users,
so the OIDC callback's hard reject breaks SSO for those deployments.

Add OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION (default off): when set,
accept the signed id_token email claim without requiring
email_verified. Default path unchanged — absent/false claims still
hard-reject. Enabling logs a startup warning plus an info line per
bypassed login. GitHub OAuth unaffected.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:00:58 +00:00
Zeyi (Rice) Fan c766433fe4 feat(dev): add omnidev, an isolated dev-pod supervisor TUI (#2146)
## Related issue

N/A

## Summary

- Add `dev/omnidev/`, a standalone Rust TUI that replaces the
  three-terminal local dev flow (`omnigent server`, `omnigent host`,
  `npm run dev`) with one long-running supervisor.
- Each checkout runs as an isolated "pod": its own state dir under
  `~/.cache/omnidev/<repo>-<hash>/`, its own SQLite DB / artifacts /
  logs, and auto-allocated server + vite ports (probed from 6767/5173,
  persisted in `pod.toml`). Isolation reuses the env-var contract proven
  by `scripts/backend-smoke.sh` (`OMNIGENT_DATA_DIR`,
  `OMNIGENT_CONFIG_HOME`, `OMNIGENT_DATABASE_URI`, `HOME`, `XDG_*`,
  `OMNIGENT_URL`).
- Supervises the three processes in their own process groups with
  health-gated startup ordering (server `/health` then host) and crash
  auto-restart with backoff; tears the whole tree down cleanly on quit.
- Restarts the backend (server then host) on debounced `omnigent/**/*.py`
  changes; the frontend is left to Vite HMR and is not watched.
- Log inspection: per-process ring buffers with scrollable panes
  (`server | host | vite | all`), follow-tail, and write-through to
  `<pod>/logs/*.log`.
- TUI styling reads on both light and dark terminals: a light neutral
  chrome bar with dark text, mid-tone per-service accent colors, and the
  log body left on the terminal's default background so ANSI colors
  render naturally. Header shows clickable `localhost:<port>` URLs while
  functional connections stay on `127.0.0.1`.
- Ignore `dev/omnidev/target/` in `.gitignore`.

## Test Plan

- `cargo build`, `cargo clippy --all-targets`, and `cargo fmt` all clean.
- `cargo test` passes 4 integration tests covering repo-root discovery,
  per-repo pod-dir stability, and port probe/persist/override.
- Verified `--help` and the out-of-repo error path, and confirmed
  `uv run omnigent --version` (the exact spawn path) resolves from the
  repo root.

## Type of change

- [ ] Bug fix
- [x] Feature
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

The TUI process-supervision loop needs a live terminal and real
child processes, so it isn't unit-tested. Pure logic (paths, ports,
pod-dir keying) is covered by `tests/pod_setup.rs`; the interactive
behavior (backend reload on a `.py` edit, Vite HMR without restart,
crash recovery, clean teardown) was verified manually per the README's
verification steps.
2026-07-07 22:25:18 +00:00
Sabhya Chhabria d356b82ac0 feat(web): add code font size + family setting for editor and terminal (#2135)
* feat(web): add code font size + family setting for editor and terminal

Settings → Appearance gains a "Code font" size stepper and family input
that drive the Monaco code editor and the xterm terminal, separate from
the chrome/UI font (which #2040/#2047 already handled and deferred code
widgets on).

Unlike the rem-based chrome — which scales off the --ui-font-scale /
--ui-font-family CSS variables — Monaco and xterm are fixed-pixel
widgets: they read an absolute size + family once at construction and
only re-measure when told to. So codeFontPreferences.ts exposes an
in-module pub/sub (subscribeCodeFont) that the write helpers fire after
persisting; mounted editors/terminals re-apply the change imperatively
(editor.updateOptions / term.options + refit) with no reload or
reconnect.

Size defaults to 13 (range 10-24); an empty family falls back to the
shared mono stack. Persisted under omnigent:code-font-{size,family}.

* feat(web): label code-font controls in full instead of a shared heading

Drop the "Code font" subheading and rename the two rows to "Code font
size" and "Code font family" so each reads unambiguously next to the
UI-font rows above. Labels only — the test-ids and the role="group"
aria-label ("Code font size") are unchanged.

* fix(web): code-font — emit intended value on write; unify empty-family default

Addresses review feedback:
- writeCodeFontSizePx / writeCodeFontFamily now broadcast the intended value
  instead of having emit() re-read storage. A failed persist (quota/denied)
  still live-applies to mounted editors/terminals rather than snapping them
  back to the stale/default stored value.
- codeFontFamilyForEditor resolves an empty family to the shared mono stack for
  Monaco too (not just the terminal), so the editor and terminal share one
  default look instead of Monaco falling back to its own built-in mono.
- Tests: a MonacoDiffViewer case asserts a mounted editor live-re-fonts via
  updateOptions; the TerminalSession setFont test asserts the refit
  (sendResize) and tolerates a down socket; module tests cover emit-on-write
  failure.

* test(e2e_ui): disambiguate font-group locators; keep comment anchor visible at 13px

The new code-font controls' aria-labels ("Code font size" / "Code font
family") contain the chrome-font labels as substrings, so the existing UI-font
e2e locators — get_by_role("group", name="Font size"/"Font family"), which match
by substring — resolved to two elements. Add exact=True to those (and the
code-font locator, defensively).

The non-markdown comment test seeded its anchor word in a trailing comment on
the longest line; at the code editor's new 13px default that line scrolls
off-screen, so the double-click word-select couldn't reach it. Move the anchor
to a short leading comment line so it stays visible at any code-font size.
2026-07-07 15:23:50 -07:00
Dhruv Gupta 473fb8123f fix(web): stop offering OpenAI Agents SDK in the agent harness picker (#2143)
The OpenAI Agents SDK (`openai-agents`) was a selectable brain harness in the
composer / new-chat / create-agent pickers for bundle YAML agents (polly, debby,
and others). Remove it as a pick by dropping its `harness_labels` entry from the
built-in harness catalog (so `/v1/harnesses` no longer lists it) and from the
static `BRAIN_HARNESS_LABELS` fallback the web merges on top — the web merge only
adds server rows, so both sources must drop it.

It stays a fully valid harness for YAML specs and remains the credential-free
mock harness the integration/e2e suites and the required `Integration
(openai-agents)` CI check depend on: only the UI picker option is removed
(valid_harnesses / harness_modules / capabilities are untouched).

Also update the e2e_ui picker assertion and the unit-test mock seeds to match.

Co-authored-by: Isaac
2026-07-07 15:20:11 -07:00
Enes Yilmaz d526b2a196 fix(claude-sdk): bind ~/.claude/.credentials.json into the sandbox (#1946)
prepare_claude_cli_path binds part of ~/.claude into the sandbox but not
.credentials.json, where the Claude CLI keeps its OAuth token on Linux. A
host-authenticated user's sandboxed claude-sdk harness saw the account
metadata in ~/.claude.json but not the token, so the CLI reported "Not
logged in". Bind the credential file alongside ~/.claude.json so a host
login works inside the sandbox.

Closes #1922

Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
2026-07-07 14:53:37 -07:00
Yuan Tang e8642d3ee3 fix(runner): cancel pending futures after asyncio.wait in _spawn_async_tool (#1945)
* fix(runner): cancel pending futures after asyncio.wait in _spawn_async_tool

When the cancel event or exec coroutine won first in asyncio.wait(),
the losing future was never cancelled, leaking tasks in long-running
sessions.

* test(runner): regression guard + caveat comments for async-tool future leak

Adds a unit test that drives the real _spawn_async_tool with a stubbed
execute_tool and asserts no asyncio task is leaked on either race outcome
(success: the orphaned cancel_event.wait(); cancel: the orphaned tool coro).
Fails on the pre-fix code, passes with the fix.

Also comments both cancel sites: the cancel-branch note records that
cancelling the task cannot interrupt an underlying asyncio.to_thread, so
that thread may still run to completion.

Co-authored-by: Isaac

---------

Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-07 21:28:14 +00:00
Edwin He 8140027a9b fix(web): let intelligent routing pick the model for claude sessions (#2136)
Intelligent routing (`databricks.mas.omnigent.intelligentRouting`) worked for
codex but not claude: claude sessions stayed pinned to Opus instead of being
routed by the judge. The server contract is correct (`if model_override is
None: route()`); two client spots re-pinned a `model_override` and tripped
that guard.

- bindStream: skip the sticky-model handoff PATCH when the session has routing
  enabled (`costControlModeOverride === "on"`), so a routing-enabled session
  isn't silently re-pinned to the last-used model.
- setCostControlMode: when routing is turned on and a model is pinned, clear
  `modelOverride` in the same PATCH (mirrors the new-chat dialog's mutual
  exclusion); skip the clear for model-less sessions so no spurious model_change
  fires.

Adds tests for the claude-native repro, the same-PATCH clear, and the
no-spurious-clear case.

Co-authored-by: Isaac
2026-07-07 13:43:48 -07:00
Yuan Tang 4947f871a1 feat(sessions): add server-side (tool, session_name) filter to child-session lookup (#1944)
* feat(sessions): add server-side (tool, session_name) filter to child-session lookup

Both _find_open_child_by_title and _find_existing_child_session were
fetching all children (100–1000 rows) and scanning in Python to match
by title. Thread the existing title column through a new exact-match
filter so the DB resolves the target in a single indexed query.

* chore: regenerate openapi.json for new child-session query params
2026-07-07 19:55:37 +00:00
Bryan Li 4225464ebd fix(antigravity-native): re-scan on bridge clear to surface deferred gates (#1472) (#1473)
* fix(antigravity-native): re-scan on bridge clear to surface deferred gates (#1472)

agy only surfaced the FIRST approval in a conversation; a subsequent gate — e.g.
the 2nd segment of a chained `a && b` run_command, each permission-gated — never
rendered an approval card and the agent hung.

Root cause: the single-in-flight guard in `_maybe_handle_interaction` skips any
new WAITING step while an interaction bridge is in flight, assuming a later
WAITING step is only ever a timeout RETRY of the gate the bridge already owns.
That holds for retries, not for a genuinely-new distinct gate. The deferred step
is never recorded in `state.interacted`, so it could surface later — but only the
poll fallback re-reads the full snapshot; the primary stream path acts only on
frames, and agy emits none while parked awaiting the gate, so the deferral is
permanent.

The guard's one-at-a-time invariant is necessary: `bridge_interaction` delivers
to the freshest WAITING step of a kind (no per-step pinning), so two concurrent
same-kind bridges would mis-target. Rather than weaken it, the bridge done-callback
now RE-SCANS the freshest steps (`_resurface_pending_interaction`) and re-dispatches
them, so a deferred gate surfaces without waiting for a stream frame.
`state.interacted` makes an already-surfaced step a no-op, so the re-scan surfaces
only the not-yet-seen gate and self-terminates, draining a chain of sequential
gates one at a time. Teardown drains the bridge + any chained re-scan tasks to
quiescence.

Tests: a deferred 2nd gate is surfaced via the clear's re-scan; the re-scan
swallows a transient steps-read error; existing guard/clear/teardown tests updated
for the no-op re-scan. Reader suite 80 pass; broader antigravity (by path) 242
pass; ruff + source mypy(strict) clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Isaac

* fix(antigravity-native): pin verdict delivery to the surfaced gate + harden teardown (#1472 review)

Adversarial-review (Codex + Opus) follow-ups on the re-scan-on-clear fix:

- Per-step DELIVERY PIN (Codex BLOCKER / Opus recommended). `bridge_interaction` now
  delivers the verdict to the step it was SURFACED for when that step is still
  WAITING (new `_waiting_step_at`), falling back to `_freshest_waiting` only when the
  captured step is gone — the genuine same-gate timeout-retry. This removes the
  unverified "agy never parallel-gates same-kind" assumption: a verdict can no longer
  land on a different higher-index gate. The timeout-retry path is preserved
  (`test_freshest_waiting_overrides_stale_captured_index` still green).

- Teardown callback flush (Codex). The drain loop yields once per pass
  (`await asyncio.sleep(0)`) so a bridge that completed NORMALLY just before teardown
  has its `_clear_slot`-scheduled re-scan land in `interaction_rescans` before the
  snapshot, instead of escaping the drain and running post-teardown.

- Tests. Add the stream-backstop "case B" (re-scan finds nothing -> a later live
  frame surfaces the gate with the slot open), the delivery-pin test (captured-WAITING
  beats a distinct higher gate), and an auto-allowed-segment edge case (an
  already-allowed command in a chain is DONE / never WAITING -> transparent to the
  re-scan, the next real gate still surfaces). Clarify the dedup-race test's intent.

- Docs. Make the sequential-gating assumption explicit in `_resurface_pending_interaction`.

Gemini review was unavailable (Google retired the Gemini Code Assist free tier the CLI
authenticated against). Verified: ruff + mypy(strict, both source modules) clean; the
antigravity suite + tests/runner/test_app_sessions_native.py (229) green; no regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Isaac

* fix(antigravity-native): drain teardown suppresses all task exceptions (#1472 review)

The interaction-bridge teardown drain awaited each cancelled task under
contextlib.suppress(asyncio.CancelledError) only. A drained task that had
already finished with a REAL exception (before the cancel landed) would re-raise
it on await, aborting the drain and leaving the remaining inflight tasks
uncancelled/unawaited (a resource leak). Each task's done-callback already logs
its exception, so the drain now suppresses (asyncio.CancelledError, Exception)
to guarantee it always runs to completion. Surfaced in adversarial review (agy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Isaac

* fix(antigravity-native): retry the bridge-clear re-scan poll so a transient blip can't strand a deferred gate (#1472 review)

The bridge-clear re-scan is the sole backstop that surfaces a deferred
chained-&& gate on the healthy-stream path (agy emits no frame while parked
and the poll loop is only the stream's failure fallback), so a single
swallowed poll error would re-introduce the permanent hang. Retry the
snapshot read a bounded number of times before giving up.

Co-authored-by: Bryan Li <bryan.li@gmail.com>
Co-authored-by: Isaac

* docs(antigravity-native): trim verbose comments in interaction re-scan code

Condense multi-paragraph inline comments and docstrings in the new
_resurface_pending_interaction / _waiting_step_at / teardown drain
code to the essential why. No logic change.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: SabhyaC26 <sabhyachhabria@gmail.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-07 14:22:56 +00:00
Praneeth Paikray e7fac09d9a feat(#900): pass files from top-level agent to subagents (copy-at-spawn) (#1041)
* feat(spawn): add file_ids to sys_session_send schema (#900)

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* feat(server): add lineage-scoped file copy endpoint for subagent file passing (#900)

Add POST /v1/sessions/{session_id}/resources/files:copy. The destination
(child) session copies parent-owned files authorized by spawn lineage:
the source must be the destination itself or an ancestor up the
parent_conversation_id chain. Each file is re-stored as a new
child-scoped row so the child reads its OWN copy — no cross-session read
grant is created, preserving the session-scoping invariant.

Co-authored-by: Isaac
Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* feat(runner): forward file_ids from parent to subagent via copy-at-spawn (#900)

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* test(e2e): file passing from parent agent to subagent (#900)

Co-authored-by: Isaac
Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* fix(#900): harden file copy — strict-ancestor source, rollback partial copies, delete phantom child

Address codex review findings:
- Reject self as copy source; require a strict parent_conversation_id ancestor.
- Prefetch blobs during validation + roll back created rows/blobs on mid-batch
  storage failure, restoring true all-or-nothing semantics.
- Delete the freshly-created server child session when copy-at-spawn fails, so a
  failed spawn cannot leave a phantom child that poisons a same-(agent,title) retry.

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* test(#900): update sys_session_send schema assertions for new file_ids field

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* fix(#900): regenerate openapi.json for copy endpoint schema

Docstring reformatting (rst -> markdown) and the sessions ->
session_resources tag move drifted the committed spec from the
generator output, failing the openapi-drift gate. Regenerate to match.

Co-authored-by: Isaac
Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* fix(#900): tear down child + defer resource events on copy-at-spawn failure

Two partial-failure bugs surfaced by cross-model (codex) review of the
copy-at-spawn path:

P1 (tool_dispatch): a named send that copied files successfully but then
failed to POST the child message only unregistered runner-local state —
it did not delete the freshly-created child like the copy-failure branch
does. That left a phantom child (poisoning a same-(agent,title) retry)
and orphaned the already-copied child-scoped file rows. Extract the
teardown into `_teardown_failed_child` and call it on every post-copy
failure path so they undo identically.

P2 (sessions copy endpoint): `files:copy` published and persisted
`session.resource.created` inside the per-file loop, before the batch
was known to succeed. A later write failure rolled back the file
rows/blobs but not those events, so clients saw phantom files. Defer all
resource events to a second loop that runs only after every write lands.

Tests: send-failure-after-copy deletes the child; mid-batch write
failure persists zero resource events and no orphan rows.

Co-authored-by: Isaac
Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* fix(#900): bound copy-at-spawn — cap files/bytes + stream one at a time

Address PattaraS's blocking review finding on PR #1041: copy_session_files
prefetched every source blob into memory before writing, so a send with many
or large file_ids was an unbounded memory spike on a shared server.

- Cap file count and summed StoredFile.bytes during metadata validation,
  BEFORE any blob is read, rejecting an over-limit request with 400 so a
  rejected request never buffers a blob.
- Limits are parameterized config knobs (copy_max_files / copy_max_total_bytes
  in server_config, defaulting to MAX_COPY_FILES=20 / MAX_COPY_TOTAL_BYTES=256
  MiB in content_resolver), overridable per deployment via the YAML config.
- Copy one file at a time (get -> create -> put) so peak memory is a single
  blob, not the whole batch; the existing rollback still gives all-or-nothing.
- Tighten the CopyFilesRequest/endpoint docstring to state the source must be
  a strict ancestor (self rejected).

Tests: over-count and over-total-bytes rejections assert 400 with ZERO blob
reads (artifact_store.get never called) and nothing copied; at-limit boundary
succeeds. Existing lineage/rollback/self-rejected coverage stays green.

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* fix(#900): enrich copy response + CopyResult dataclass (PR #1041 nits)

Two non-blocking nits from PattaraS's review of PR #1041:

nit #1 — the copy response returned only an id mapping, so the runner
dispatch path did an extra metadata GET per file and guessed content-type
from the filename, even though the true content_type is preserved at copy
time. CopyFilesResponse.mapping now carries {new_id, filename, content_type}
per file (new CopiedFile model); _build_subagent_message_content reads the
type straight from the response — dropping N round-trips — and only falls
back to a filename guess when the source row had no recorded type.

nit #3 — _build_subagent_message_content returned a clunky
tuple[list, None] | tuple[None, str] (value, error) union. Replace it with a
small frozen CopyResult(content, error) dataclass; the single dispatch call
site branches on result.error.

Also regenerated openapi.json for the tightened CopyFilesRequest/endpoint
docstrings (strict-ancestor wording).

Tests: dispatch asserts the content type comes from the copy response with
ZERO per-file metadata GETs, plus a no-content_type→filename-fallback case;
endpoint tests assert the enriched {new_id, filename, content_type} mapping.

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* fix(#900): probe artifact_store.exists during copy validation

Codex review of the cap-and-stream change flagged a regression: moving to
metadata-only validation dropped the original "missing source blob surfaces
before any child row is created" guarantee. A blob that failed mid-stream
(dangling row: metadata present, blob gone) would only surface after earlier
files were already written, leaning on best-effort rollback.

artifact_store.exists() is a cheap metadata probe (S3 HEAD / local stat / DB
row) — NOT a blob read — so calling it in the validation pass restores the
fail-before-any-write guarantee without reintroducing the batch prefetch or
spiking memory.

Test: a source whose blob was deleted (row intact) → 404 with nothing copied.

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>

* fix(files): address review feedback

---------

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>
Co-authored-by: praneeth_paikray-data <praneeth.paikray@databricks.com>
2026-07-07 12:40:45 +00:00
Vadim Comanescu 77b211cd72 fix(web_fetch): run __web_researcher on the parent leg's harness (#1725)
* fix(web_fetch): run __web_researcher on the parent leg's harness

web_fetch does not fetch directly: it dispatches a synthetic __web_researcher
sub-agent that runs curl via sys_os_shell. build_researcher_spec built that
child as a bare ExecutorSpec(max_iterations=5), copying only the parent's llm
and dropping the parent's executor harness, auth, and model. With executor.type
defaulting to "omnigent" and an empty config, every fetch broke on every leg:

- Layer 1 (active): executor.harness_kind (config["harness"] or type) resolved
  to the literal "omnigent", so the runner aborted the researcher spawn with
  `RuntimeError: unknown harness 'omnigent'` before any model routing.
- Layer 2 (latent): with the parent's harness and auth gone, a gateway model
  such as z-ai/glm-5.2 fell through to the in-process native router
  (`Unknown provider 'z-ai'`), and the codex/claude legs failed on missing
  credentials.

PR #817 reconstructs the researcher on a resolve-miss but calls the same
build_researcher_spec, so the bug persisted.

Fix: inherit the parent executor fields the harness spawn-env builders actually
read on the claude-sdk/codex/pi legs — config["harness"] (selection;
runner/app.py:8691,18601), model (_resolve_spec_model; workflow.py:1115), and
auth (_resolve_provider_for_build; workflow.py:1040) — plus type, the executor
discriminator. connection (rides on llm), context_window (auto-detected), and
the deprecated Databricks profile (subsumed by auth) are not read on these legs
and are omitted. os_env carried inside executor.config is an inline-sub-spec
artifact superseded by the explicit os_env, so it is dropped.

A parent's real harness can also live only in resolved session state (an API
harness_override on a spec with no config["harness"]); that is not visible at
the build_researcher_spec call sites (WebFetchTool.__init__ and the
_find_spec_by_name resolve-miss), and the researcher child never carries an
override. Rather than emit a child that the runner aborts with the cryptic
unknown harness 'omnigent', fail loud at build time with an actionable
OmnigentError naming the parent leg.

Add regression tests: the reconstructed spec carries the parent's
harness/auth/model (not the bare type=="omnigent"/no-harness spec); the inline
executor.config os_env is dropped; a no-harness parent raises the clear error.

Signed-off-by: Vadim Comanescu <vadim984@gmail.com>

* docs(web_fetch): trim verbose build_researcher_spec comments

The inline commentary in build_researcher_spec had grown to multi-paragraph
blocks with file:line references. Condense to the essential why (inherit the
parent leg's routing fields; fail loud on no bootable harness) per the repo's
comment guidance. No logic change.

---------

Signed-off-by: Vadim Comanescu <vadim984@gmail.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-07 12:04:53 +00:00
Serena Ruan ae3bbebe69 docs(queue-steer): reorder shipped; steer mechanism code-confirmed per harness (#2084)
Reorder is no longer an optional follow-up — drag-to-reorder (grip handle,
within-conversation) shipped, so the actions table reflects it.

Update the per-harness steer table from this session's code audit: cursor-,
pi-, hermes-, opencode-native all report supports_live_message_queue = True
(opencode via supports_enqueue=True through NativeServerHarness), so the steer
button is honored on all of them. opencode-native is settled — its app server
has no live-steer endpoint, so a steered message is admitted as a new prompt
and promoted by the server's own queue at the next turn boundary.

Narrow the TODO: the delivery mechanism is now code-confirmed for every native
harness; what remains is upgrading the app-defined (mid-turn vs next-turn)
rows via a LIVE steer per harness — confirmed live only for claude-/codex-
native so far.

Co-authored-by: Isaac
2026-07-07 18:55:17 +08:00
Serena Ruan a1ddce3b03 fix(claude-native): reach input prompt under unbounded subagent footer (#2089)
A web-UI message injected while Claude Code is mid-turn still rendered a
spurious "terminal did not become ready within 30s" runtime-error card
when many subagents ran concurrently. The readiness gate scans for the
`❯` input glyph; PR #2001 widened the scan to an 8-line box-rule-framed
window to clear a one-subagent footer, but a subagent fan-out adds one
`○ Explore …` row per concurrent subagent, so the footer height is
unbounded — five subagents push `❯` to the 12th line from the bottom,
past the fixed window, and the gate times out.

Drop the fixed framed window: scan all visible non-empty lines for a `❯`
that has a box rule below it. The box rule (the input box's closing
`────` frame) is a reliable structural signal at any depth, and
`capture-pane -p` returns only the visible pane, so the scan stays within
one screen. The scrollback-echo false positive stays rejected — an echoed
`❯` never has a box rule beneath it.

Co-authored-by: Isaac
2026-07-07 18:47:24 +08:00
Serena Ruan ac39c38a88 feat(web): generate a worktree branch name from the new-session composer (#2094)
A sparkle button inside the "Git worktree branch" input fills a unique
"worktree-<hex>" name (crypto.randomUUID), so users can spin up a
throwaway worktree without inventing a branch name.

Co-authored-by: Isaac
2026-07-07 18:46:53 +08:00
Tomu Hirata 1d165d160b feat(db): add scope column to policies table (#2091)
Adds an explicit policies.scope column ('default' | 'session') so queries
can filter by column value instead of checking session_id IS NULL — the same
pattern used for agents.kind (o1a2b3c4d5e6). Includes a SQLite-safe Alembic
migration (q1a2b3c4d5e6) with back-fill, a partial unique index on default
policy names, and corresponding store, entity, and test updates.
2026-07-07 10:05:38 +00:00
Serena Ruan c641d0deff feat(web): make sidebar Search open the command palette (#2086)
* feat(web): make sidebar Search open the command palette

The sidebar's "Search sessions" box was an inline filter that only
narrowed the visible list. Session search (title + chat content) already
lives in the ⌘K command palette, so point the box at it instead of
duplicating a weaker filter.

- Sidebar: replace the search input with a "Search" button that opens the
  palette, showing a ⌘K badge on hover/focus. Drop the inline
  searchQuery/debounce state; the list is now unfiltered.
- CommandPalette: list Sessions above Actions (the palette doubles as the
  session-search entry point). Cap the session list to 5 while the query
  is empty so Actions stays visible without scrolling; typing lifts the
  cap. Indent session rows to align with the icon-prefixed actions.
  Placeholder → "Search sessions or run a command".
- AppShell: wire the button to the palette; mount the palette in embedded
  mode too (the ⌘K hotkey stays disabled there).

Co-authored-by: Isaac

* test(e2e-ui): regenerate visual baselines

* test(e2e-ui): retarget sidebar search tests to the command palette

The sidebar's "Search sessions" input became a "Search" button that opens
the command palette, so the two E2E tests that located the old searchbox
were failing.

- test_sidebar_hotkeys: probe sidebar collapse/expand width via the
  "Search" button (data-testid=sidebar-search-button) instead of the
  removed search input.
- test_sidebar_search: drive the server-side search round-trip through the
  palette (opened from the Search button) — matching query lists the
  session, non-matching empties it — the same chain the old inline filter
  exercised.

Co-authored-by: Isaac

* test(e2e-ui): fix sidebar search tests for the palette (verified locally)

The first retarget pass had two real bugs, both now reproduced and fixed
against a local live server + Chromium:

- test_bracket_chord: the collapse probe measured the search control's
  width, but the new Search button (a flex item, min-width:auto) floors at
  its content width and stays 260px on collapse — the old input shrank to
  0. Probe the sidebar <aside> width instead; it's what the chord animates.
- test_sidebar_search: the session title also renders in the chat header
  (the test is on /c/{id}), so a page-wide text match never reached zero.
  Scope both palette assertions to the dialog.

Co-authored-by: Isaac

---------

Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-07-07 17:57:40 +08:00
Serena Ruan 90b0cbe72e feat(web): select an existing git worktree when starting a session (#2088)
* feat(web): select an existing git worktree when starting a session

The new-session worktree field previously only created a new worktree
off a branch name, and picking a directory that was already an existing
worktree errored ("branch already exists"). This adds first-class
support for starting a session directly in an existing worktree.

The branch input is now a combobox: focusing it lists the repo's
existing worktrees, typing filters them, picking one starts the session
in that worktree (no git opts sent — so no branch-already-exists guard),
and a name matching none creates a new worktree as before. A concise
warning flags that the session starts in an existing worktree.

Backend adds a read-only list_worktrees host git op, the matching
list_worktrees tunnel frame pair, a server proxy, and
GET /hosts/{id}/worktrees (owner-scoped; non-git path → 400 → empty
list in the picker), mirroring the existing create/remove worktree
plumbing.

Co-authored-by: Isaac

* fix: prettier-format worktree UI + regenerate openapi.json

CI caught two gaps: the new worktree combobox files weren't
prettier-formatted, and the new GET /hosts/{id}/worktrees route made
the checked-in openapi.json stale. Regenerated via scripts/dump_openapi.py.

Co-authored-by: Isaac

* test(e2e-ui): cover selecting an existing worktree in start-session

Drives the branch combobox end-to-end: focusing it lists the repo's
existing worktrees (stubbed GET /hosts/{id}/worktrees), selecting one
points the workspace at that dir and sends no git spec on create.
Mirrors the existing test_start_session_add_worktree harness.

Co-authored-by: Isaac
2026-07-07 17:32:48 +08:00
Serena Ruan 7d9dd710d2 fix(claude-native): clear busy state after in-pane /model switch (#2082)
Native Claude sessions stayed "busy" in the web UI (composer stuck on
Stop) after a /model switch, even though the terminal was idle. It
self-healed only on the next real message.

A surfaced CLI built-in (/model, /effort) becomes a slash_command
transcript item that opens its own response id but runs no LLM turn, so
no Stop hook ever fires to close it. The forwarder's turn-start edge
still published an id-bearing running for it, which opened a streaming
activeResponse in the web store; the store suppresses the trailing bare
PTY idle while a response is streaming, so nothing cleared it.

Gate the turn-start running edge on the turn actually having assistant
output (a function_call or assistant message) — the exact turns a later
Stop/StopFailure hook will close. Turns that produce no LLM output
(slash_command, or terminal_command from !cmd) no longer strand the UI
busy. A skill that does trigger an LLM turn shares its id with the
assistant text it produces, so running still fires one poll later when
that output appears.

Co-authored-by: Isaac
2026-07-07 17:31:33 +08:00
Tomu Hirata 4845b82187 refactor(db): remove all FK constraints (Rule R032) (#2081)
* refactor(db): remove all FK constraints; application owns relationship cleanup

Drops all 9 FK constraints (8 CASCADE + 1 SET NULL) from the SQLAlchemy
models and adds a new Alembic migration (p1a2b3c4d5e6) to remove them from
the live schema, following internal DB standard Rule R032.

- db_models.py: remove ForeignKey() from session_permissions.user_id,
  session_permissions.conversation_id, conversations.parent_conversation_id,
  conversations.root_conversation_id, conversations.agent_id,
  conversations.host_id, conversation_items.conversation_id,
  conversation_labels.conversation_id, and policies.session_id.
- migration p1a2b3c4d5e6: upgrade drops all FKs via batch_alter_table
  (recreate="always" on SQLite); downgrade re-adds them.
- delete_conversation: now collects the full conversation subtree via a
  recursive CTE and explicitly deletes items, labels, comments, policies,
  and session-permissions for all descendants before deleting conversation
  rows, replacing the previous reliance on ON DELETE CASCADE.
- switch_conversation_agent: removes the defensive null+flush of agent_id
  before deleting the old session-scoped agent, since there is no longer
  a CASCADE constraint that would destroy the conversation row.

* test(db): update tests for FK removal; fix migration and ORM cascade assertions

- Fix migration p1a2b3c4d5e6 to correctly drop all FKs on SQLite by
  reflecting actual constraint names (including unnamed/None FKs that get
  convention-derived names during batch rebuild) and drop_constrainting each.
  Restore host_id FK in downgrade as fk_conversations_host_id_hosts to match
  the original name so subsequent migrations can find it.
- Restore row.agent_id = None + flush before deleting old agent in
  switch_conversation_agent so SQLAlchemy ORM identity map stays consistent.
- Update ORM cascade tests to assert new no-FK behavior (children survive
  parent deletion; app must clean up explicitly).
- Update migration_workspace test to document that host deletion no longer
  auto-nulls conversations.host_id without a DB FK.
- Update permission store cascade test to document that permissions persist
  after conversation deletion without DB FK cascade.
- Update agents migration FK test to document that referential integrity is
  now the application's responsibility.

* fix(db): explicit cleanup in delete_user and delete_host after FK removal

delete_user now explicitly deletes session_permissions rows before
removing the user row — without the DB CASCADE, orphaned permissions
could grant access to a re-created account with the same identifier.

delete_host now explicitly nulls conversations.host_id for any sessions
still bound to the host before deleting the row — replaces the removed
ON DELETE SET NULL FK behavior. Also updates stale FK-reference comments.
2026-07-07 18:19:16 +09:00
Arthur Liao fc0a4dae42 fix(spec): expand env vars in builtin tool config (#2064)
Co-authored-by: Arthur Liao <223135116+zycaskevin@users.noreply.github.com>
2026-07-07 08:26:21 +00:00
Pat Sukprasert d7e74a0d64 feat(harness-bench): full-server default + --fast, parallel runs, rich progress, transport labels (#2059)
* feat(harness-bench): rich live progress, --jobs parallel, --report file

Three CLI/output improvements, built on a structured progress-event seam.

- Structured events (events.py): the orchestrator now emits typed BenchEvents
  (HarnessStarted/Skipped, ProbeStarted/Finished, HarnessFinished) to a
  ProgressSink, instead of pre-rendered strings. The old per-line output is
  preserved via LineSink, and a bare-callable `progress=` is auto-adapted to
  it — back-compat, no caller change required.

- Rich live table (richreport.py, --rich/--no-rich): a ProgressSink backed by
  rich.Live draws one row per harness with per-dimension cells that fill in as
  probes finish (spinner while running → verdict glyph). Auto-selected on a
  TTY when rich is available; falls back to LineSink under a pipe/CI or when
  rich is absent (rich_sink_or_none returns None). Most useful with --jobs.

- Bounded parallel (--jobs N / -j, default 1): run up to N harnesses
  concurrently via an asyncio.Semaphore. Probes WITHIN a harness stay
  sequential (they share one driver/session with a single in-flight turn);
  concurrency is only across harnesses, each of which owns its own
  server/runner. gather preserves input order, so the matrix stays in
  --harness order regardless of finish order. The cap keeps process/port and
  gateway load bounded rather than spawning every harness at once.

- Report file (--report PATH): write the final matrix to a file; format from
  --json/--markdown, else inferred from the extension (.json/.md), else a
  plain (un-colored) grid.

Tests: structured-event emission + LineSink adaptation, --jobs order
preservation under staggered finishes, and --report file writing (md + json).
Offline suite 55 passed / 14 skipped, ruff clean. rich renders live when
present; the plain path is unchanged.

* feat(harness-bench): share one server+runner across parallel full-server harnesses

Folds the shared-server optimization into the parallel path. Previously each
full-server harness spawned its own server + runner; under --jobs > 1 that was
N server boots + N runners. The Omnigent server is multi-agent/multi-session
and a single runner resolves the harness per session from its agent spec, so N
SDK harnesses can share ONE server+runner, each registering its own agent +
session.

- New SharedFullServer (full_server_driver.py): owns the server+runner
  lifecycle + agent/session registration, extracted from FullServerDriver.
- FullServerDriver takes an optional `shared=`: injected → registers on the
  shared server and spawns nothing; None → owns a private SharedFullServer
  (back-compat, exactly the old one-server-per-harness behavior for --jobs 1).
- run_bench stands up one SharedFullServer for a live, parallel run with >1
  full-server harness (via _maybe_shared_full_server), passes it to each, and
  tears it down after. native-tui harnesses still self-provision (each needs
  its own host daemon).

Cuts the heaviest, slowest part of full-server startup (server boot +
health-wait) from N times to once, and roughly halves the process/port count
for a parallel SDK run. Gateway load is unchanged (same total turns).

Test: a parallel full-server run builds exactly one SharedFullServer and all
harnesses register on it. Offline suite 56 passed / 14 skipped, ruff clean;
solo full-server path unchanged (back-compat).

* refactor(harness-bench): split shared server into its own module; hoist imports

Readability/structure cleanup requested in review, no behavior change.

- Split full_server.py out of full_server_driver.py: the server+runner
  lifecycle and agent/session registration (SharedFullServer + spawn/wait/
  config helpers + the shared _find_free_port/_mint_bearer/spawn_omnigent_server
  that native-tui also uses) now live in full_server.py; full_server_driver.py
  keeps just FullServerDriver and its probe/item-scan helpers. Clear seam:
  "the server" vs "the driver that runs probes against it".
- Hoist function-body imports to module top across the package (Any, shutil,
  cli_unavailable_reason, omnigent.harness_capabilities/plugins, LineSink,
  SharedFullServer, socket/io/tarfile/yaml). The only inline imports left are
  intentional and now commented: the optional `rich` dependency (richreport +
  its lazy load in __main__) and two documented cycle-avoidance imports
  (transport→drivers, profile→manifest).
- Update consumers (native_tui_driver, bench) to import the shared helpers
  from full_server; fix the shared-server test to patch bench's namespace
  (bench now imports SharedFullServer at top).

Offline suite 56 passed / 14 skipped, ruff clean, no import cycle.

* feat(harness-bench): default SDK harnesses to full-server; add --fast

Full-server is a strict coverage superset for SDK harnesses: it observes
everything sdk-inproc does (basic / streaming / interrupt / model-override)
*plus* the two dimensions sdk-inproc physically cannot reach — Tool calling
and Policy DENY, as server-dispatched, policy-gated calls. The only cost is
the server boot. So make full-server the default and offer --fast as the
opt-out, rather than a per-harness --best selector.

Transport is now resolved from the harness *family* + flags
(resolve_transport_name):

- SDK family (sdk-inproc/full-server) -> full-server by default; --fast picks
  sdk-inproc (skips the boot; Tool calling + Policy DENY then report SKIPPED,
  which those probes already emit on the wrap-direct path -- no false DRIFT).
- native (native-tui) -> single transport; --fast does not apply.
- --transport NAME still overrides the family for any harness, and is mutually
  exclusive with --fast.

The profile's `transport` field stays the family marker (the _is_native
applicability gate keys on it), so nothing about probe applicability changes.
--list now prints the resolved default transport so it matches what runs.

Both driver gates already agree with this: FullServerDriver.unavailable only
rejects native profiles (not sdk-inproc-family), and SdkInprocDriver accepts
its own family -- so neither default nor --fast self-rejects.

Docs (harness-bench-design.md) updated: transport-selection prose, the
which-transport-exercises-what table, and the run examples now lead with the
full-server default and --fast opt-out.

Offline suite 57 passed / 14 skipped, ruff clean.

* fix(harness-bench): quiet expected provisioning skips; keep tracebacks for bugs

A parallel live run dumped three full tracebacks for the own-auth natives
(goose/kimi/hermes) whose forwarder never wires up — an expected, already-
handled skip (they show as skipped in the matrix), but the stack dumps break
up the --rich table and read like failures.

Introduce ProvisioningError (in driver.py) for an *expected* provisioning
failure: a known-unrunnable environment through no fault of the bench, e.g. an
own-auth native whose vendor CLI is installed but not logged in. native-tui's
forwarder-timeout now raises it instead of a bare RuntimeError.

run_harness splits on it: an expected ProvisioningError logs one INFO line
(reason only, no traceback), while any other exception keeps exc_info=True so a
genuine driver bug (e.g. an AssertionError) can't vanish behind a green skip.
The matrix output is unchanged either way — the harness is still a
capability-neutral skip with the reason shown in its row.

Offline suite 58 passed / 14 skipped, ruff clean.

* feat(harness-bench): label each matrix row with its resolved transport

Show which transport actually produced each row, e.g. `claude-sdk
[full-server]`, `kimi-native [native]`. This matters now that transport is
resolved from family + flags: an SDK harness's profile.transport is the
`sdk-inproc` family marker, but it runs on `full-server` by default -- so the
label reflects the *resolved* transport, not the marker, or it would mislabel
exactly the rows worth clarifying.

- HarnessReport carries the resolved `transport` (the driver class's transport,
  or the resolve_transport_name result offline). Populated at every report site
  (success, unavailable-skip, provisioning-skip, offline).
- report.py labels the harness column in both the terminal and Markdown
  renderers (native-tui abbreviated to `native`); render_json adds a distinct
  `resolved_transport` field alongside the family `transport`.
- The rich live table labels its rows too: HarnessSkipped gained a transport
  field (HarnessStarted already had one), and the sink tracks harness→transport.

Offline suite 58 passed / 14 skipped, ruff clean.

* docs(harness-bench): refresh README for phase-2 state

The README still described the phase-1 MVP (sdk-inproc only, four SDK
harnesses, Markdown/JSON output). Bring it current:

- Run examples lead with --jobs + --rich; add a Flags section covering
  --fast, --transport, --jobs, --rich/--no-rich, --report.
- New "Transport selection" section: full-server is the SDK default (fullest
  coverage), --fast opts down to sdk-inproc, natives use native-tui.
- Note the per-row transport label and that Tool calling / Policy DENY only
  get a real verdict on full-server.
- Layout table lists the current modules (transport.py, full_server.py split
  from full_server_driver.py, native_tui_driver.py, events.py, richreport.py).
- Scope reflects what is live (3 transports, all natives auto-derived) vs the
  remaining open items, instead of "phase-1 MVP".

* docs(harness-bench): clarify native Tool calling / Policy DENY is a bench gap

A reader skimming the matrix could misread the `·` in the native rows'
Tool calling / Policy DENY cells as "native harnesses can't do this". They
can -- the bench just cannot observe it on native-tui yet.

Sharpen both docs to say so unambiguously:
- A `·` always means "the bench did not measure this here", never "the harness
  lacks it".
- The native-tui `·` for those two dimensions is a driver/observation gap, not
  a native-harness limitation: a native tool call is the vendor's own
  (Bash/Read/...) and a native deny is a vendor permission decision, neither of
  which is the server-dispatched, policy-gated call the probe watches for.
- The which-transport table cells now read "bench can't observe vendor tools/
  deny yet" instead of the terse "not yet wired"; the open-items entries lead
  with "bench observation ... a driver gap, not a native-harness limitation".

No behavior change; docs only.

* fix(harness-bench): treat any native provisioning failure as a quiet skip

The earlier quieting only covered the forwarder-timeout RuntimeError. A native
harness can fail provisioning other ways -- goose-native's terminal-ensure
returns a 500 (the vendor cannot start a thread), which raised a raw
httpx.HTTPStatusError and still dumped a full traceback.

Native provisioning drives a live vendor CLI plus a server-native terminal, so
any HTTP failure there is an environment/server-state gap, not a bench bug.
NativeTuiDriver.__aenter__ now converts httpx.HTTPError into ProvisioningError
so the orchestrator skips the harness quietly (reason shown in its row). A
programming error (AssertionError, etc.) is not an HTTPError, so it still
propagates with its traceback. The deliberate readiness-timeout and
agent-not-seeded raises in the provisioning path also became ProvisioningError
for consistency.

Test: an httpx 500 in provisioning surfaces as ProvisioningError. Offline suite
59 passed / 14 skipped, ruff clean.

* test(harness-bench): single import style in test_bench (review)

Code-quality review flagged tests.harness_bench.bench being imported both as
`from ... import run_bench, run_harness` (top level) and `import ... as
bench_mod` (in three test bodies). Drop the in-function module aliases and
patch module attributes via monkeypatch's string-target form
(`"tests.harness_bench.bench.resolve_driver_class"`), which the file already
uses elsewhere -- so there is one import style throughout.

No behavior change. Offline suite 59 passed / 14 skipped, ruff clean.

* fix(harness-bench): don't reprint the grid under --rich on a terminal

Running `--rich` interactively showed the matrix twice: the rich live table
(progress, on stderr) and then the plain report grid (deliverable, on stdout),
which land on the same terminal and look like a duplicate.

The report is not pure duplication -- it carries the legend, per-cell Notes,
and any Drift section the rich table omits. So the fix keeps the footer and
drops only the grid, and only when it would actually duplicate:

- render_table gains grid=True/False; grid=False emits just the footer
  (legend/drift/notes/skips), no heading or glyph rows.
- Sinks expose drew_grid (rich live table True, LineSink False). The CLI prints
  grid=False only when the sink drew the grid AND stdout is a TTY (same
  terminal as the stderr progress). Redirect stdout to a file and the report
  keeps the full grid, so the file stays self-contained.

Tests: grid=False drops the grid but keeps the legend; _grid_already_shown is
True only for a grid-drawing sink. Offline suite 61 passed / 14 skipped, ruff
clean. README output-format note updated.
2026-07-07 16:12:00 +08:00
Tomu Hirata 279b7e0c13 refactor(agents): remove Agent<->Conversations double reference (#2069)
Drop the back-pointer `agents.session_id` column (FK to
`conversations.id`) in favour of the forward pointer
`conversations.agent_id`, which was already the canonical source of
truth. An agent is now classified as session-scoped if any conversation
row references it via `conversations.agent_id`, discovered at query time
with a NOT EXISTS subquery rather than a nullable FK column.

- Remove `session_id` from `SqlAgent`, `Agent` entity, and the
  `sql_agent_to_entity` converter.
- Rewrite `get_by_name` and `list` template-agent filters from
  `session_id IS NULL` to `NOT EXISTS (SELECT … FROM conversations …)`.
- Drop the partial unique index `ix_agents_template_name` (was scoped
  to `session_id IS NULL`) and recreate it as a plain unique index;
  drop `ix_agents_session_id`.
- Add Alembic migration `o1a2b3c4d5e6` with upgrade/downgrade paths.
2026-07-07 08:03:07 +00:00
Serena Ruan 79aa9963a8 feat(web): drag-to-reorder queued messages (#2078)
Queued messages could be steered, edited, or deleted, but not reordered —
the queue drained strictly in enqueue order. Add drag-to-reorder so the
user can change the order their held follow-ups will send in.

Each strip row gains a grip handle (shown only when reordering is wired);
dragging it reorders via @dnd-kit/core primitives — the same pointer
sensors the sidebar uses (5px mouse activation, so a grip click still
reaches the row's steer/edit/delete buttons). A dedicated handle rather
than a whole-row drag keeps those buttons clickable.

New reorderQueuedMessage(queueId, beforeQueueId) store action does the
move. queuedMessages is one flat array interleaving conversations, so it
reorders only within the dragged message's own conversation and refills
that conversation's absolute slots — other conversations' entries keep
their positions. No-ops on a missing id, a self-move, or a cross-
conversation target.

Tests: store reorder (before/end, no-op identity, interleaved-queue slot
preservation, cross-conversation guard) and the strip's grip affordance
gating on onReorder.

Co-authored-by: Isaac
2026-07-07 15:22:18 +08:00
Serena Ruan 511932e83c fix(web): reuse prior file upload on message retry (#2075)
Polly flagged a duplicate-upload leak on #2065 that also pre-exists in
send(): when a message with attachments retries after a post-phase failure
(background flush re-queues on a cooldown; send() is retried by the caller),
the retry re-uploads every File from scratch, orphaning the blobs the first
attempt already stored server-side.

Add a shared uploadFileBlock(sessionId, file) helper that memoizes each
File's successful upload (WeakMap keyed by File, then by session) and
returns the cached content block on a retry instead of re-uploading. Wire
both send() and flushBackgroundQueues through it. The WeakMap auto-releases
once the File is dropped from the queue/pending state.

Tests: a send() retry after a failed post reuses the cached file_id (one
upload, not two); the background-flush retry does the same and the posted
message still carries the original id.

Co-authored-by: Isaac
2026-07-07 14:48:42 +08:00
Serena Ruan 012721e4d0 feat(web): background-flush queued messages with attachments (#2065)
* feat(web): background-flush queued messages with attachments

Background cross-session flush previously skipped any queued message that
carried files, leaving it for the foreground flush — so an image queued in
a navigated-away conversation sat until the user returned.

Mirror send()'s two-phase sequence in flushBackgroundQueues: upload each
attachment via uploadFile (→ real file_id), build input_image/input_file
blocks, then post the message referencing them via postEvent. Both awaits
sit under the one in-flight guard and the one catch, so a failure in either
the upload or the post phase re-queues the head (FIFO-preserving) and sets
the same cooldown — no separate guard, no double-send.

Removing the files skip also closes the head-blocking edge: an image at the
head of an idle conversation's queue now drains instead of stalling the
text messages behind it.

Tests: upload-then-post emits an image block with the real file_id and
clears the queue; an upload-phase failure posts nothing and re-queues.

Co-authored-by: Isaac

* test(e2e): background-flush a queued image to its origin session

Adds a cross-session e2e alongside the text one: attach an image + text to
B while B is busy (held POST), switch to idle A, release B. Asserts the
background flush uploads the image to B then posts an input_image block
carrying the returned file_id — and that neither the upload nor the message
leaks into the active session A.

Covers the two-phase upload→post path end-to-end (the unit tests cover it
at the store level); shares the seeded_session_pair fixture and route-mock
harness with the text test.

Co-authored-by: Isaac
2026-07-07 14:16:26 +08:00
Anthony Ivan 7a8fcf931b feat(web): keep the working indicator lit for the whole turn, rotate its label (#2006)
* feat(web): keep the working indicator lit for the whole turn, rotate its label

The Otto + shimmer "Working…" indicator was hidden the moment an assistant
bubble began streaming, so long tool runs and reasoning gaps looked stalled.
Keep it lit for the entire busy turn (only a trailing compaction spinner still
suppresses it), and rotate its label through a short pool for variety.

- shouldShowWorkingIndicator no longer hides on a streaming bubble; drop the
  now-unused hasInProgressAssistantBubble helper.
- Add useWorkingLabelTick: one shared wall-clock timer (useSyncExternalStore)
  so both render sites rotate in lockstep. ROTATE_MS = 1 minute.
- workingIndicatorLabel(bgCount, tick) cycles WORKING_MESSAGES (7 labels,
  index 0 = "Working…"); background-task counts still take priority.
- Keep the pinned pill's aria-live announcement stable at "Working…" while
  only the visible tab text rotates, so screen readers aren't re-announced.

Reduced motion needs no change: the shimmer sweep and Otto bob already freeze
via CSS, and the label is a JS text swap so it keeps rotating.

Co-authored-by: Isaac

* fix(web): address PR review — drop "Thinking…" label, fix e2e assert

Review follow-ups on #2006:
- Remove "Thinking…" from WORKING_MESSAGES — it carries a specific
  reasoning/thinking meaning in the LLM context (per @daniellok-db).
- Update the background-task e2e (test_background_task_indicator_label_lifecycle)
  now that the running-turn label rotates: assert on the trailing ellipsis
  every rotating label shares (the background-task text has none) instead of
  the literal "Working", so it's robust to which pool entry the wall-clock
  bucket lands on.

Co-authored-by: Isaac

* test(e2e): match working label against the pool, not the ellipsis

Per review follow-up: assert the running-turn indicator shows one of the
actual rotating labels (regex alternation over the WORKING_MESSAGES mirror)
rather than the trailing ellipsis. A commented _WORKING_LABELS constant
mirrors the web pool and must stay in sync if it changes.

Co-authored-by: Isaac

---------

Co-authored-by: Anthony Ivan <anthony.ivan@example.com>
2026-07-07 13:54:33 +08:00
Tomu Hirata 75a5ec58db feat(cli): add omni session export --id <session_id> (#2021)
* feat(cli): add omni session export --id <session_id> command

Closes #1623

* test(cli): add unit tests for omni session export

* fix(test): rename l -> line to fix E741 ambiguous variable name

* feat(cli): switch session export to use server API via --server

* fix(cli): pass auth headers to session export HTTP client
2026-07-07 05:35:22 +00:00
Serena Ruan 62b4254aff ci(e2e-ui): cache the sidecar binary and skip recompiles (#2028)
The `build codex-parity sidecar` job recompiles the Rust sidecar (~1100
crates, ~7 min cold) on nearly every PR run. The old `Cache Rust build`
step cached the whole 1.6 GB `--target-dir` keyed on `Cargo.lock`, but:

- The job triggers only on `pull_request`, so every cache is scoped to
  `refs/pull/NNNN/merge`. GitHub only lets a PR restore caches from its
  own ref or the base branch (main), and this workflow never writes a
  main-scoped cache -- so no PR can ever restore another's. Every first
  run is a guaranteed cold miss.
- Each 1.6 GB entry churns out of the 10 GB repo cache under LRU, so
  even same-PR re-runs frequently miss.
- Even on a target-dir hit, Cargo re-fingerprints and rebuilds anyway.

Mirror the fix #2016 applied to ci.yml's codex-parity job: cache just
the ~10 MB binary, keyed on `sidecar/**` + the rustc version, and skip
`cargo build` on a hit. This uses the SAME key as ci.yml, which runs on
push to main -- so the main-scoped `codex-parity-bin` cache ci.yml
produces is now restorable by this PR-only workflow. Warm runs drop from
~7 min to the artifact download/upload (~15-25s). The key self-
invalidates when the source, Cargo.lock, or toolchain changes.

Co-authored-by: Isaac
2026-07-07 11:54:50 +08:00
Serena Ruan e6cbd35410 feat(web): background cross-session flush of queued messages (#2029)
* feat(web): background cross-session flush of queued messages

A message queued in conversation B now flushes when B goes idle, even
while the user is viewing a different conversation A — previously it sat
until the user returned to B (navigating away aborts B's SSE stream, so
the foreground flush couldn't see B's status).

New flushBackgroundQueues store action: for each conversation with queued
messages that isn't the active one, read its status from the live
["conversations"] cache (kept fresh by the WS session-updates overlay +
poll) and, if idle, POST the head via postEvent — a stateless primitive
that touches no active-session state (no optimistic bubble; it re-hydrates
on return). One message per idle conversation per call (FIFO); re-queues
on POST failure to retry. Text-only for now — attachments are left to the
foreground flush (tracked in the code comment).

A new app-wide QueueFlushProvider triggers it on queue changes and on any
["conversations"] cache change (the signal a navigated-away conversation
went idle). The foreground maybeFlushQueuedHead still owns the active
conversation; the two are complementary.

Updates the cross-session routing e2e: it now asserts the queued message
is delivered to its origin B via background flush (never leaking to the
active A) — closing the loop the pre-queue test guarded.

Co-authored-by: Isaac

* fix(web): bound background-flush retries on persistent POST failure

Polly review flagged an unbounded retry storm: on a persistent POST
failure the head is re-queued, which mutates queuedMessages and re-fires
QueueFlushProvider's effect; the failed POST leaves the conversation idle
in the cache, so it flushes → POSTs → fails → re-queues → … with no
backoff, hammering /v1/sessions/{id}/events.

Add a module-level throttle (kept out of store state so it can't
re-trigger the effect): skip a conversation that is mid-POST or within a
5s post-failure cooldown. Also re-queue a failed head ahead of its own
successors instead of at the tail, preserving per-conversation FIFO.

Tests: cooldown blocks an immediate re-POST of a just-failed conversation;
a failed head lands back in front of its successor.

Co-authored-by: Isaac
2026-07-07 11:22:03 +08:00
Sabhya Chhabria 53883864af feat(web): add UI font family setting to Appearance (#2047)
* feat(web): add UI font family setting to Appearance

Add a font-family control to Settings → Appearance, beside the font-size
stepper. It's a free-text field (Cursor-style): type any font installed on
this device; leave it blank for the system default. The choice re-fonts the
whole UI chrome, is persisted per-device in localStorage, and is applied
before first paint so a reload doesn't flash the default.

Implementation mirrors the just-merged font-size setting (#2040). It can't
reuse --font-sans: Tailwind v4's @theme inline block inlines the literal
stack into the font-sans utility rather than a var() reference, so a runtime
--font-sans override is a no-op. Instead the html rule reads
font-family: var(--ui-font-family, var(--font-sans)), and the preference
module sets --ui-font-family on documentElement — unset falls back to the
existing system stack. The theme picker and font-size stepper are unchanged.

The two .font-heading elements (dialog/card titles) resolve font-family:
var(--font-sans) directly, so they keep the system stack rather than the
custom family — acceptable for this UI-chrome-only change.

Co-authored-by: Isaac

* fix(web): keep font-family input inline; ruff-format e2e test

- The Font family row's longer description pushed the input onto its own
  line under flex-wrap. Give the text column min-w-0 flex-1 and the control
  shrink-0 so the input stays flush-right on the same row as the label,
  matching the font-size stepper above it.
- Apply ruff format to the new e2e test (one-line test signature) so the
  Pre-commit CI check passes.

Co-authored-by: Isaac

* fix(web): right-align font-family input with the font-size stepper

Move the Reset button to the left of the input so the input is the
rightmost element in its group; its right edge now lines up flush with
the font-size stepper above it (both at the row's right edge). Reset
stays `invisible` (not removed) at the default so the row doesn't shift.

Co-authored-by: Isaac

* fix(web): keep code surfaces on the mono font, immune to the UI font setting

The UI font-family setting is UI chrome only. Pin the Monaco editor and
xterm terminal roots (.monaco-editor, .xterm) to var(--font-mono) so the
--ui-font-family override can't leak into code surfaces through an unpinned
descendant. Editor/terminal code fonts are intended for a separate, future
code-font setting.

Both surfaces already pin their own font (xterm via its JS fontFamily
option, Monaco via its inline default), so this is a defensive guard;
verified live that with a UI font override active, .xterm/.xterm-screen and
the Shiki code viewer all stay on the mono stack.

Co-authored-by: Isaac

* fix(web): fall back to the default sans for unknown/partial font names

Applying a bare `--ui-font-family: <name>` meant that a font that isn't
installed — or a partial name while the user is still typing — left the
browser with an unresolvable family and no fallback, so the UI dropped to
the browser's default serif (Times) instead of the app's sans.

Append the system stack to the applied value (`<name>, var(--font-sans)`)
so an unusable name degrades to the default sans. The CSS-level
`var(--ui-font-family, …)` fallback only fires when the property is unset,
not when it holds an unusable value, so the fallback must live in the value
too. localStorage still stores just the raw name (the input shows it
verbatim). Verified live: partial/uninstalled names now render as the
default sans, not serif.

Co-authored-by: Isaac

* test(e2e): assert font-family starts with the chosen name

The applied --ui-font-family now leads the chosen family and appends the
system stack as a fallback, so getComputedStyle resolves the custom
property to the full stack (e.g. "Georgia, ui-sans-serif, ..."). Assert the
resolved value startswith the typed name rather than equals it. The
reset/empty assertions are unchanged (property removed → empty).

Co-authored-by: Isaac
2026-07-07 08:43:37 +05:30
Dimitar Dimitrov 52ec40109d feat(web-ui): global command palette (Cmd/Ctrl+K) (#1386)
* feat(web-ui): global command palette (⌘K)

Add a cross-platform command palette opened with ⌘K (Ctrl+K on
Windows/Linux), with two groups:

- Actions: New chat, Go to Inbox/Settings, toggle the conversations and
  workspace sidebars, and open the keyboard-shortcuts dialog. Filtered
  client-side against the query.
- Sessions: fuzzy session switching from the same server-search source the
  sidebar uses (useConversations → GET /v1/sessions?search_query=),
  debounced, so the palette finds sessions beyond the first page rather than
  client-filtering one page. Archived excluded, matching the sidebar default.

The hotkey is bound once in AppShell and bails when focus is inside an xterm
terminal or the Monaco editor (both own ⌘K), and is disabled in embedded
mode where ⌘K belongs to the host page. The desktop (Electron) app loads the
same SPA and binds only ⌘N/⌘F natively, so ⌘K reaches the renderer unchanged.

Adds an 'Open command palette · ⌘K' row to the keyboard-shortcuts dialog, a
ResizeObserver test polyfill cmdk needs under jsdom, colocated Vitest
coverage, and a Playwright e2e (tests/e2e_ui/sessions/test_command_palette.py).

Signed-off-by: Dimitar Dimitrov <dimitardimitrov9205@gmail.com>

* feat(web-ui): reuse UI icons in command palette, drop shortcuts action

Give each palette Action the same icon as its equivalent button
elsewhere in the UI (new chat, inbox, settings, sidebar toggles) so the
palette reads as a shortcut to those surfaces. Icons inherit the item's
foreground color rather than the muted tone, matching the label text.

Remove the "Keyboard shortcuts" action — the palette is for imperative
commands, not opening an informational dialog. Widen the palette so the
two columns of longer session labels aren't cramped.

Co-authored-by: Isaac

---------

Signed-off-by: Dimitar Dimitrov <dimitardimitrov9205@gmail.com>
Co-authored-by: Dimitar Dimitrov <dimitardimitrov9205@gmail.com>
Co-authored-by: Daniel Lok <daniel.lok@databricks.com>
2026-07-07 10:40:59 +08:00
ShiZai e83b11ea1a fix(kimi-native): mirror reasoning (think blocks) to the web transcript (#1677)
The kimi-native forwarder only mirrored `content.part` of type `text`, so
Kimi's reasoning (the `think` block shown in the TUI) never reached the web
conversation — the forwarder's own docstring acknowledged it as "skipped for
v1". The reasoning text lives in `part["think"]`, not `part["text"]`.

Mirror a `think` part as a one-shot transient `external_output_reasoning_delta`
(`started: true`) so the web UI paints a reasoning block — the kimi analogue of
the codex-native fix in #1254, where the project settled this as a required
native-harness capability. `tool.call` / `tool.result` mirroring is left as a
separate follow-up.

Update the existing `_row_to_item` test that asserted think parts are skipped to
assert they now produce a reasoning item.

Closes #1676

Signed-off-by: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com>
Co-authored-by: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com>
2026-07-07 01:41:31 +00:00
ShiZai 8236c72890 fix(harnesses): re-check idleness before the reaper releases an entry (#1834)
The idle reaper snapshots its stale list under the registry lock, then
releases each entry outside it; a single teardown can hold the pass
open for seconds (graceful-SIGTERM wait). A turn that starts on a
later-listed conversation during that window refreshes last_used_at
and marks itself in flight — but release() tore the entry down without
re-checking, SIGTERMing the subprocess mid-turn. Users saw a turn on a
long-idle session die seconds after it started with a harness stream
connection error.

release() now takes only_if_idle_cutoff (passed only by the reaper):
under the registry lock, atomically with the unregister, it skips
entries that were touched after the pass cutoff or have a turn in
flight — they are reclaimed by a later pass once genuinely idle.
Mirrors the pane reaper's busy re-check immediately before teardown.

Signed-off-by: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com>
Co-authored-by: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com>
2026-07-07 01:10:44 +00:00
Vadim Comanescu a0e6f511ec fix(runtime): tolerate missing lsof in orphan sweep (#1266)
Signed-off-by: Vadim Comanescu <vadim984@gmail.com>
2026-07-06 18:06:14 -07:00
Dhruv Gupta e1ee55aa4d feat(ci): enforce a 5-working-day reviewer SLA on PRs and issues (#2042)
Scheduled weekday sweep (github-script, modeled on stale.yml +
auto-assign-reviewer) that escalates open PRs/issues an assigned
maintainer has sat on for >5 working days with no reply:

- PRs: re-ping the requested reviewer + add a second reviewer
  (lowest-load owner of the touched area(s) in .github/areas.json,
  mirrored as an assignee).
- Issues: re-ping the assignee + add a second assignee from the owners
  of the area(s) whose comp:* label the issue carries.
- Escalate-once, guarded by BOTH a one-shot `review-sla-escalated` label
  and a hidden marker in the comment, so even a failed label write can't
  cause daily re-nudging. The second reviewer is added first (best-effort),
  so the comment only claims a reviewer that actually attached.
- Cap escalations at 30 per sweep so an existing stale backlog drains
  gradually instead of firing all at once, and count each second reviewer
  against the in-sweep load so picks rotate across maintainers instead of
  concentrating on the current lowest-load one.

Ownership is read from .github/areas.json -- the single source of truth
shared with auto-assign-reviewer.js and issue triage. Runs from the
trusted default branch (reads no PR code). Offline unit test
(review-sla.test.js, 47 assertions, ownership pinned to a fixture) drives
both paths through a mocked client; review-sla-test.yml runs it in CI.

Co-authored-by: Isaac
2026-07-06 17:47:24 -07:00
Sabhya Chhabria 541b451338 fix(web): allow free editing of the UI font size input (#2053)
The Appearance font-size box bound directly to the clamped, committed value
and clamped on every keystroke, so backspacing "13" to "1" snapped straight
to the 12px minimum — you couldn't clear the field or type toward a target.

Decouple the box's displayed text (a free-form draft) from the committed
value: typing shows whatever you enter, applies live only once the draft is a
valid in-range whole number, and clamps + re-syncs on blur/Enter (an empty or
below-min entry settles to the committed size or the minimum). The steppers
still commit and keep the text in sync.

Co-authored-by: Isaac
2026-07-07 06:05:24 +05:30
Pat Sukprasert 5269f70ecf docs(harness-bench): refresh design doc to shipped reality; expand (#2023)
The design doc had drifted from what actually shipped, and the seam doc
carried a superseded streaming rule. Bring both current:

designs/harness-capabilities-bench-seam.md
- Correct the group-B streaming rule: False → UNSUPPORTED, not PARTIAL.
  PARTIAL is a probe observation (coalesced single delta), never declared.
  Add the "declare False only from a live 0-delta observation" rule (a static
  forwarder grep is insufficient — pi-native disproved it).

docs/harness-bench-design.md
- Add a Status banner up top and a "Current state (shipped)" section: three
  transport drivers (sdk-inproc / full-server / native-tui), the six P0
  probes, capability-derived matrix, native auto-derivation — and what is not
  yet wired.
- Replace the stale "Phasing" (which framed native/full-server as future P1;
  both shipped) and refresh "Transport drivers" for the semantic-method driver
  design that exists now.
- Note that entry-point plugin discovery now exists (updates the "no discovery
  mechanism" constraint), so the bench side of option B is realized.
- Fix the streaming section: only kiro/cursor/qwen are declared non-streaming
  (all live-verified 0 deltas), not the earlier blanket seven.
- New "Plugin seamlessness" section: the bench is plugin-ready, but the
  server's native-agent seeding is a hardcoded list (the real remaining seam);
  the registry-driven-seeding fix closes it.
- New "self-enforcing table in practice" section: kiro/pi/cursor/qwen drift
  case studies as worked examples of detect → diagnose → correct-the-source.
- Refresh Open items (drop resolved ones; add the seeding refactor, native-tui
  tool/policy, and the per-harness provisioning gaps the bench surfaced).

Docs only; no code change.
2026-07-07 08:30:35 +08:00
Tomu Hirata a5818fc8b0 fix(policies): register legacy nessie handler paths in policy registry (#2048)
* fix(policies): register legacy nessie handler paths in registry

Deployed bundles referencing omnigent.inner.nessie.policies.* were
rejected at session creation because the registry no longer listed
those handler paths after BUILTIN_POLICY_MODULES dropped the shim.

Add the shim back to BUILTIN_POLICY_MODULES with its own POLICY_REGISTRY
that advertises the legacy paths, so old bundles pass validation while
the canonical paths remain under omnigent.policies.builtins.orchestration.

* fix(policies): hide legacy nessie paths from UI with internal_only=True
2026-07-07 00:05:31 +00:00
Dhruv Gupta 779aa99385 fix(runtime): route bare claude-* compaction model to Anthropic (#1950) (#2043)
Explicit /compact on a claude-sdk agent with a pinned bare Anthropic
model (e.g. claude-haiku-4-5-20251001) returned a 500 from the
summarization endpoint. Compaction's Layer-2 summarizer uses the generic
runtime LLM client, whose parse_model_string defaults any prefix-less
model id to OpenAI -- so the Anthropic model id was sent to
api.openai.com, which rejects it, and explicit /compact
(fail_on_summary_error=True) surfaces that as INTERNAL_ERROR (500).

_route_databricks_model_for_compaction already normalized bare
databricks-* ids for this exact reason. Generalize it to
_route_bare_model_for_compaction, which also prefixes bare claude-* with
anthropic/. Already-prefixed ids and bare gpt-* are left untouched.

Co-authored-by: Isaac
2026-07-06 22:26:03 +00:00
Sabhya Chhabria 6a97848fc6 feat(web): add UI font size setting to Appearance (#2040)
* feat(web): add UI font size setting to Appearance

Add a font-size control to Settings → Appearance that scales the whole
interface. The web UI is Tailwind v4 (typography and spacing in rem), so
scaling the root font-size reflows everything uniformly — the same lever
the mobile bump already uses.

The choice is stored as an absolute px value (default 16, range 12–20) and
applied as a --ui-font-scale multiplier on the document root, so it composes
with the mobile @media bump instead of overriding it. Applied before first
paint to avoid a flash, and persisted per-device in localStorage.

The control is a segmented pill ([ − | value | + ]) styled after Cursor's
appearance settings. The theme picker is unchanged.

Co-authored-by: Isaac

* test(e2e): cover UI font size setting

Add a Playwright test mirroring test_theme_toggle.py for the new
Appearance font-size stepper: stepping the value updates the applied
--ui-font-scale on <html> and persists the px choice across a reload,
and the −/+ buttons disable at the 12/20 bounds.

Co-authored-by: Isaac
2026-07-07 03:19:49 +05:30
David O'Keeffe 16a636366e feat(claude-native): add Fable and both Sonnet generations to model selection (#1981)
* feat(models): add Fable 5 and Sonnet 5 to Claude subscription model list

Adds claude-fable-5 and claude-sonnet-5 to the curated subscription
model catalog alongside the existing claude-sonnet-4-6 (kept since
Sonnet 4.6 remains the only option in some regions/workspaces).

* fix(tests): update sys_list_models CI assertion for Fable 5 / Sonnet 5

test_sys_list_models_dispatches_locally_with_static_provider asserted
the old 3-model curated list; missed when claude-fable-5 and
claude-sonnet-5 were added to _SUBSCRIPTION_STATIC_MODELS.

* feat(claude-native): surface Sonnet 4.6 as a distinct /model picker option

Claude Code's /model picker has one fixed alias per family (fable/opus/
sonnet/haiku) plus exactly one extra custom slot
(ANTHROPIC_CUSTOM_MODEL_OPTION). With both claude-sonnet-4-6 and
claude-sonnet-5 in active use, pin the newest Sonnet to the "sonnet"
family alias and the older one to the custom slot so both stay
independently selectable, instead of one silently shadowing the other.

- claude_native.py: a new "sonnet_4_6" key in ucode's claude_models
  sets ANTHROPIC_CUSTOM_MODEL_OPTION(_NAME) alongside the existing
  per-tier ANTHROPIC_DEFAULT_*_MODEL pins.
- claude_native_forwarder.py: _model_alias_for now special-cases
  sonnet-4-6 ids to the "sonnet_4_6" alias before the generic
  "sonnet" substring match (a 4.6 id also contains "sonnet").
- claudeNativeModels.ts: adds a "Sonnet 4.6" row; isModelImplicitlySelected
  gets the same 4.6-vs-generic-sonnet disambiguation as the backend.

* feat(claude-native): re-enable Fable picker row, label Sonnet rows by version

Fable access is restored, so the withheld row returns. The generic
"Sonnet" row is relabelled "Sonnet 5" so the two Sonnet options read
unambiguously side by side; the id stays the version-agnostic "sonnet"
alias.

* test(e2e-ui): cover the claude-native picker's Fable + dual-Sonnet rows

Asserts the five picker rows and labels, that a bound
databricks-claude-sonnet-4-6 model highlights the Sonnet 4.6 row rather
than the generic Sonnet row, and that picking Sonnet 4.6 PATCHes
model_override and updates the trigger label.

* fix(claude-native): keep Sonnet 4.6 default; add Sonnet 5 as opt-in

#1981 relabelled the primary "sonnet" alias to "Sonnet 5" and put Sonnet
4.6 on Claude Code's one custom /model slot — which presents the newest
Sonnet as the default. Flip it so the default is left alone:

- The "sonnet" alias stays bound to the workspace's existing default
  Sonnet (4.6); it's only relabelled "Sonnet 4.6" so it reads clearly
  next to the new row. Its model binding is unchanged.
- Sonnet 5 rides the single custom slot (ANTHROPIC_CUSTOM_MODEL_OPTION,
  tier "sonnet_5") as an explicit opt-in, not a repointed default.
- Disambiguation (forwarder _model_alias_for + web isModelImplicitlySelected)
  routes concrete sonnet-5 ids to the opt-in row; sonnet-4-6 collapses to
  the default "sonnet" alias.
- Flip the corresponding unit + e2e assertions.

Builds on #1981 by @dgokeeffe. Fable row + catalog additions unchanged.

Co-authored-by: Isaac

---------

Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-06 21:41:38 +00:00
Krzysztof Zarzycki ecb7350cde fix(claude-native): emit compactMetadata on resume compact_boundary (#1957)
Resumed claude-native transcripts write a compact_boundary head marker
without a compactMetadata object. Claude Code scans every compact_boundary
on each compaction and destructures compactMetadata, so a missing object
crashes both manual /compact and auto-compaction on resume with:

  Error during compaction: Cannot destructure property
  'cumulativeDroppedTokens' from null or undefined value

Every subsequent compaction rescans the same transcript and fails the same
way, wedging the session once context fills.

Emit compactMetadata (trigger + postTokens from the item's token_count).
Claude reads every sub-field via ??, so a minimal object is sufficient.

Closes #1955

Signed-off-by: Krzysztof Zarzycki <4157788+kzarzycki@users.noreply.github.com>
Co-authored-by: Krzysztof Zarzycki <4157788+kzarzycki@users.noreply.github.com>
2026-07-06 21:38:44 +00:00
ychamare 7fc9cee923 feat(desktop): opt-in macOS notification sound, with a turn-end settle (#1864)
The macOS desktop app raised OS notifications when a session needed
attention (a turn finishing, the agent asking for input, a runner
disconnecting) but never played a sound, unlike the iOS app. Add an
opt-in notification sound driven entirely from the desktop shell, and
stop step-by-step agents from sounding on every milestone.

Desktop shell (web/electron/src/main.js):
- New macOS "Notifications" menu: a "Play Notification Sound" toggle
  (OFF by default — the user opts in) and a picker of the system sounds
  in /System/Library/Sounds (default Glass); selecting one previews it.
  Persisted in settings.json, read live so a change applies to the next
  notification.
- The notify handler plays the chosen sound via `afplay` in both the
  foreground and background — macOS mutes the frontmost app's own
  notification sound, so we mute the toast and play it ourselves, audible
  either way and never doubled. A per-session throttle guards a burst.

Notification timing + focus (web/src/hooks/useIdleNotifications.ts):
- Defer a turn-end notification by a 10s settle and cancel it if the
  session resumes to running, so a multi-step agent that streams
  milestones notifies once at the end instead of once per step. A new
  elicitation ("needs response") still fires immediately.
- A session is suppressed while the user is actively viewing it (window
  focused AND it's the open conversation). Window focus is read from the
  authoritative focus/blur events (and any pointer/key interaction) rather
  than a polled document.hasFocus(), which the Electron shell could
  misreport.
- Skip notifications for a session whose runner is offline: when nothing
  is actively running, the only thing that flips a session terminal is the
  server reconciling a dead-runner session (a stale `running` dropping to
  `failed`/`idle`), not a real completion — so it must not beep. Stops the
  phantom beep after the app sits idle with only stale sessions left.
- Beep a session's turn-end at most once until the user views it: a
  session that finishes again while its notification is still outstanding
  does not ring again. This also collapses the multiple turn-ends a single
  async task produces (launching subagents, then reporting back) into one
  beep. The mark clears when the user views the session.

Docs: web/electron/README.md (notification, foreground-cue, and menu
bullets) and the README desktop blurb.

Tests: useIdleNotifications.test.tsx covers the settle, the
focus-from-events fix, the offline-runner filter, and the re-notification
dedup. tests/e2e_ui/sessions/test_idle_notifications.py adds a Playwright
test asserting the turn-end settle deferral end to end — a backgrounded
turn-end stays silent through the settle window, then lands exactly once.

Co-authored-by: Isaac

Signed-off-by: Yuri Chamarelli <yuri.chamarelli@databricks.com>
Co-authored-by: Yuri Chamarelli <yuri.chamarelli@databricks.com>
2026-07-06 14:23:27 -07:00
Zero Qu 3d230c50be fix(runner) Share MCP servers across specs (#1948)
* fix(runner): share mcp servers across specs

* fix(runner): address mcp pool review feedback

* fix(runner): harden shared mcp connect lifecycle

* test(runner): stabilize terminal attach spawn tests
2026-07-06 20:19:04 +00:00
Daniel Lok 25307a9be2 fix(web): keep button width stable while loading (#2032)
Submitting the Codex goal dialog rendered a spinner as an extra child
next to the label, widening the button and shifting its neighbours. The
shared Button had no loading state, so every caller inlined its own
spinner beside the text.

Add a `loading` prop to Button that overlays a centered spinner and
hides the label in place (`display: contents` + `invisible`), preserving
the button's width and the flex gap, and forces disabled + aria-busy.
The four Codex goal dialog actions now pass `loading` instead of
inlining a spinner.

Co-authored-by: Isaac
2026-07-06 20:38:47 +08:00
Yuan Tang 8a482c1cd7 fix(web): persist brain-harness override across sessions (#1904)
* fix(web): persist brain-harness override across sessions

The per-session brain-harness pick (e.g. claude-sdk vs openai-agents for
bundle agents like Polly) was lost on page refresh because it only lived
in a module-scoped variable. Persist it to localStorage keyed by agent id
so returning users land on the harness they last chose.

* style: fix prettier formatting in NewChatDialog

* fix(web): persist harness under correct agent id on submenu switch

Address Polly AI review feedback:

- Pass the target agent id from the picker when switching agents via
  the harness submenu, so the preference is stored under the correct
  agent instead of the stale effectiveAgentId from the prior render.
- Fix docstring in harnessPreferences.ts that falsely claimed the
  consumer validates stored values against the harness vocabulary.
- Update stale comment on pickedHarness state that still said
  "cleared on every agent switch" (now seeds from stored preference).
2026-07-06 19:01:08 +08:00
Serena Ruan 156cb03190 feat(web): enable steer for native terminal sessions (#2025)
Show the queued-message Steer button on native sessions too, not just SDK.
The runner delivers a steered message uniformly for every native harness
(POST → buffer → drain → hand to app; each native run_turn returns right
after delivering the input), and the app folds it into the running turn:
deterministically for codex-native (turn/steer RPC) and claude-native (the
TUI folds a pane paste), best-effort for the rest.

Removes the isNativeTerminalSession gate on onSteer (and its now-unused
subscription). steerMessage is harness-agnostic — it just POSTs now.

Verified live: claude-native, codex-native. cursor/pi/hermes/opencode-native
(and the others) get the button too — the mechanism is uniform — but their
mid-response behavior is not yet verified live (tracked as a TODO in
docs/QUEUE_STEER_DESIGN.md; opencode notably has no steer endpoint and queues
as a new prompt).

Co-authored-by: Isaac
2026-07-06 18:52:08 +08:00
Anas Khan de651a9f83 fix(web): fold the reversed native-opencode alias to opencode-native (#1929)
The server accepts both native-opencode and opencode-native (harness
aliases), but the web HARNESS_ALIASES map omitted native-opencode, so
nativeCodingAgentForHarness("native-opencode") returned undefined and an
opencode agent forked/switched under that spelling rendered as plain chat
instead of the native terminal wrapper. Add the missing reversed entry.

Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
2026-07-06 18:28:28 +08:00
Tomu Hirata c2060cdf90 feat(intent-gate): return ASK instead of DENY for off-task tool calls (#2024)
* feat(intent-gate): return ASK instead of DENY for off-task tool calls

Switches intent_gate from blocking off-task tool calls outright to
prompting the user for approval, letting them decide whether to proceed.

Also extracts _off_task_reason() to deduplicate the reason string
shared between the cache-hit and fresh-classification paths.

* refactor(intent-gate): rename intent_gate to intent_based_authorization

* refactor(intent-gate): rename display name to Intent Based Authorization

* fix(lint): wrap long log strings in intent_based_authorization
2026-07-06 10:11:01 +00:00
Serena Ruan 687db94b32 feat(web): steer a queued message (SDK harnesses) (#2022)
* feat(web): steer a queued message (SDK harnesses)

Adds a per-row steer (send-now) button to the composer's queued strip:
clicking it POSTs that message immediately instead of waiting for the idle
flush. On an SDK harness the server live-injects it into the running turn;
the optimistic bubble promotes on POST. It sends to the agent captured at
enqueue time and can jump ahead of earlier queued messages.

Gated to non-native sessions: native terminals buffer & drain rather than
inject mid-turn, so no steer button is shown there until that path lands
(tracked in docs/QUEUE_STEER_DESIGN.md).

Co-authored-by: Isaac

* fix(web): label steer action and drop the Queued tag

Replace the icon-only steer button with a labeled '↳ Steer' (corner-down-
right arrow + text) and remove the redundant 'Queued' tag — the strip's
position above the composer already signals queued state.

Co-authored-by: Isaac

* test(e2e_ui): steer a queued message sends it mid-turn

Drives the SPA against a spawned server: a first message is acked but
never gets a session.status event, so the session stays busy; a follow-up
queues in the docked strip; clicking Steer POSTs it immediately — which
can only happen via steer, since the session never went idle to trigger
the auto-flush. Asserts the steered message POSTs and leaves the queue.

Co-authored-by: Isaac
2026-07-06 18:04:51 +08:00
Serena Ruan 31db1dcafe feat(web): edit a queued message from the composer strip (#2019)
* feat(web): edit a queued message from the composer strip

Each queued row gets a pencil button that pulls the message back into the
composer for editing: its text and attachments load into the composer, the
entry is removed from the queue, and the textarea is focused. Any
in-progress draft is preserved (prepended). Re-sending re-queues it (busy)
or sends it (idle).

Stacked on the delete PR.

Co-authored-by: Isaac

* fix(web): edit replaces composer content instead of prepending

Editing a queued message now replaces the composer's text and attachments
with the queued message's, rather than prepending to an in-progress draft
— prepending was surprising when the composer already held content.

Co-authored-by: Isaac
2026-07-06 17:16:23 +08:00
Pat Sukprasert 8552d68c7e fix(server): seed goose-native-ui and hermes-native-ui default agents (#2018)
_ensure_default_agents in server/app.py seeded 9 of the 11 native-ui agents
declared in the harness registry (harness_plugins.native_agents) — goose and
hermes were added to the registry but their startup seeders were never wired
in. So `GET /v1/agents` never listed goose-native-ui / hermes-native-ui, and
anything resolving a native agent by that name (the harness bench, and any
head that relies on the built-in row) failed with "not auto-registered".

Add the two missing seeder pairs (_build_*_native_bundle + _ensure_default_*
_agent), mirroring the kiro pattern exactly, and call them from
_ensure_default_agents. goose/hermes have the required _materialize_*_agent_spec
functions already; only the app.py wiring was missing.

Verified: with this change both goose-native and hermes-native get PAST agent
registration in the harness bench (they now reach terminal provisioning, where
each hits a separate downstream issue — hermes a lazy-chat/first-turn gate,
goose a terminal-ensure 500 — tracked separately). test_native_coding_agents
passes; ruff clean.

Note: the per-harness hardcoded seeder list is itself the seam — a native
plugin is invisible until hand-added here. Making _ensure_default_agents
iterate native_agents() from the registry (which already includes plugins) is
the follow-up that would close it.
2026-07-06 09:03:50 +00:00
Serena Ruan 2d18ec2cd0 feat(web): delete a queued message from the composer strip (#2010)
* feat(web): delete a queued message from the composer strip

Each queued row gets a hover/focus-revealed remove button that drops it
from the client-side queue via a new dequeueMessage(queueId) store action.

Stacked on the client-side message queue foundation.

Co-authored-by: Isaac

* fix(web): make queued-message delete button always visible

The remove button was hover-gated (opacity-0 → group-hover), so the
delete affordance was undiscoverable — users couldn't tell a queued
message could be removed. Show it persistently at reduced opacity;
it brightens on hover/focus.

Co-authored-by: Isaac

* fix(web): use trash icon for queued-message delete

Swap the ✕ for a trash icon so the delete affordance reads as delete,
not dismiss.

Co-authored-by: Isaac
2026-07-06 16:55:00 +08:00
Pat Sukprasert 8452ce39d5 fix(harness-caps): only declare streaming=False where live-verified (revert #1990 over-reach) (#2007)
* fix(harness-caps): only declare streaming=False where live-verified (revert #1990 over-reach)

#1990 flipped 7 transcript-mirror natives to streaming=False from a static
"forwarder posts no external_output_text_delta" grep. A live bench run
disproved that for pi-native: it has no delta-posting forwarder yet streams 7
token deltas (its Pi extension emits them by another path), so it drifted
!!✗>✓ (declared UNSUPPORTED, observed SUPPORTED).

The static grep is not a sound basis for asserting a harness does NOT stream.
Revert pi/cursor/goose/qwen/kimi/hermes to streaming=True (their pre-#1990
value, the honest default); keep streaming=False only for kiro-native, which
is live-verified (0 deltas over a full SSE capture). The remaining five are
unverified on this host (own-auth logins the bench can't provision); leaving
them True means the bench will flag a real drift if any turns out not to
stream, rather than asserting an unproven False that drifts the moment the
harness does stream (as pi just showed).

Offline suites: 60 passed / 14 skipped, ruff clean.

* docs(harness-caps): don't claim an unverified emission path for pi-native

The comment asserted pi-native "emits [deltas] by another path" — an inference
that was never traced, the same unverified-assertion habit that caused the
original wrong flip. Soften to the observed fact only: it streams 7 deltas
live, by a path not traced. No behavior change.

* fix(harness-bench): support lazy-chat natives (cursor); mark cursor/qwen non-streaming

Two findings from an all-native bench run:

1. cursor-native could not provision — "native forwarder did not wire up within
   90s (no external_session_id)". Root cause: cursor creates its chat id
   (external_session_id) lazily, only after the FIRST message lands
   (cursor_native_forwarder.py), but the driver hard-gated provisioning on that
   id BEFORE posting any turn — a deadlock. claude/codex stamp it at TUI launch,
   so the gate worked for them. Add a per-vendor `lazy_chat` flag (NativeVendor)
   and skip the pre-turn external_session_id gate for those vendors; the first
   probe turn triggers the chat and the forwarder discovers it then. cursor is
   the only known lazy-chat native today. Live-verified: cursor-native now
   provisions and runs (Basic/Model-override/Interrupt SUPPORTED).

2. With cursor now runnable, its Streaming observed 0 deltas — and qwen-native
   likewise (0 deltas) in the same run. Both were declaring streaming=True and
   drifting !!✓>✗. Set streaming=False for cursor-native and qwen-native, joining
   kiro-native — all three now LIVE-VERIFIED non-streaming (0 deltas observed),
   consistent with the "only declare False where observed" rule.

Offline: 60 passed / 14 skipped, ruff clean.
2026-07-06 16:46:35 +08:00
Sunny Yang a4d0f2789e feat(web): render .ipynb notebooks as read-only previews in the file viewer (#1848)
* feat(web): render .ipynb notebooks as read-only previews in the file viewer

Notebooks currently open as raw JSON in Monaco, which is unusable for
reviewing notebook-heavy work. Add a NotebookPreview that renders cells
in order — markdown through the existing react-markdown/GFM pipeline,
code through the shared Shiki CodeBlockContent with execution counts,
and outputs from each cell's mime bundle — with zero new dependencies.

Output handling is safety-first: text/html is never injected into the
DOM (rich outputs like pandas DataFrames fall back to their text/plain
repr with a note), only raster image mimes render as inert data-URIs
(SVG excluded), and stream/error outputs go through the same
ansi-to-react the terminal uses, so colored tracebacks render properly.

Notebooks join markdown/html as previewable: preview is the default
view, with the raw-JSON Monaco source view kept as the escape hatch.
Invalid or truncated notebook JSON shows a parse-error state pointing
at the source view.

* fix(web): make notebook preview robust to real-world .ipynb quirks

The NotebookPreview handled clean, spec-perfect notebooks but broke on
files exported by real kernels:

- Recover from raw C0 control chars (unescaped ANSI in tracebacks/output)
  that strict JSON.parse rejects with "Bad control character in string
  literal" — retry once after escaping stray control chars inside string
  literals.
- Strip all whitespace (not just \n) from base64 image payloads; a
  data-URI containing CRLF or spaces is rejected by the browser as a
  broken image.
- Validate base64 before building the data-URI (charset + length % 4);
  on a corrupt payload show a "could not be decoded" note and fall back
  to the text/plain repr instead of an ERR_INVALID_URL broken image.
- Let long unbreakable traceback runs (separator rules, paths) scroll
  within the cell (overflow-x-auto + overflow-wrap:anywhere) instead of
  widening the whole preview.

Adds regression tests for each case.

Co-authored-by: Isaac

---------

Co-authored-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-06 16:37:12 +08:00
Serena Ruan 47aedd525f feat(web): client-side message queue with auto-flush on idle (#2008)
* feat(web): client-side message queue with auto-flush on idle

Follow-ups typed while the agent is busy are now held in a client-side
queue shown in a docked strip above the composer, instead of being POSTed
immediately. The queue head flushes FIFO (one per turn) when the session
goes idle.

The flush is level-triggered — a store action (maybeFlushQueuedHead)
re-evaluated on every status/queue change and on enqueue — so a message
queued just after a turn ends, or after an SSE reconnect that carries no
fresh idle transition, still sends instead of stranding.

In-memory only (no persistence); a hard reload clears the queue.
Per-message actions (delete / edit / steer / reorder) land in follow-ups.

Co-authored-by: Isaac

* fix(web): address queue review — per-conversation flush + edge cases

Fixes from the PR review of the client-side message queue:

- Blocking: flush the first message OF THE BOUND CONVERSATION, not the
  global array head. The queue is one flat array across conversations, so
  an undrained message from another conversation sat at index 0 and
  permanently blocked the bound conversation's messages (the same
  never-sends stranding the feature set out to fix). Regression test
  covers a foreign head in front of a local entry.
- Pin the agent at enqueue time so a message flushes to the agent it was
  composed for even if the binding changed (e.g. a /model switch).
- Hold the flush while the session is unreachable so it doesn't POST into
  a void, bypassing the reconnect dialog; drains once reachable again.
- Clear a conversation's queue when it is deleted so entries bound to a
  dead session can't linger in memory.

Each fix has a regression test verified to fail without the fix.

Co-authored-by: Isaac

* test(e2e_ui): rewrite cross-session routing test for client-side queue

The client-side message queue changes the routing model the old test
encoded: a follow-up typed while a session is busy is now held in that
session's client-side queue instead of being POSTed on the module-level
send chain. The old repro (hold msg1's POST → msg2 queues on the chain →
switch sessions → chain unblocks → msg2 POSTs to origin) no longer
applies, so the test timed out waiting for a msg2 POST that never fires.

Rewritten to assert the same no-leak guarantee under the new model: a
message queued in B (busy) is held client-side, and switching to idle
session A must never flush it into A. The positive FIFO-flush-on-idle
path is covered by the chatStore unit tests.

Also fixes a real gap the rewrite surfaced: the flush effect now depends
on boundAgentId, so a queue drains correctly when a conversation binds
after navigation (the binding lands after the status settles).

Ran locally against a built web UI: 1 passed.

Co-authored-by: Isaac
2026-07-06 16:23:01 +08:00
Anas Khan 61f6b725b5 feat(openai-agents): stream reasoning deltas as ReasoningChunk (#1647)
The openai-agents harness only handled response.output_text.delta, so a
flagship harness forwarded no reasoning while claude/codex/antigravity all
emit ReasoningChunk. Surface the Responses-API reasoning deltas
(response.reasoning_summary_text.delta and response.reasoning_text.delta)
as ReasoningChunk(event_type="reasoning_text") when non-empty, mirroring
codex. The reasoning_item ghost stays in _NON_OUTPUT_ITEM_TYPES; only the
streaming deltas are mirrored.

Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
2026-07-06 08:21:20 +00:00
Tomu Hirata 50faf0200b fix(policies): show page in single-user/header mode regardless of admin gate (#2017)
In header/single-user mode the backend already skips admin enforcement,
but the frontend was still waiting on an identity probe that never
resolves an is_admin flag, leaving the page stuck on "Loading..." or
showing the "no permission" message. Mirror the MembersPage pattern:
derive isSingleUser from useServerInfo and bypass the admin gate
entirely when true. Also adds unit tests for the single-user path.
2026-07-06 08:18:06 +00:00
Bryan Qiu 6b48cb06fe fix(web): prevent editor crash on blockquote with inline-only content (#2004)
A markdown file containing a blockquote whose only content is a lone
inline image (`> ![x](img)`) or an empty blockquote (`>`) crashed the
markdown editor's panel.

@tiptap/markdown (beta) parses those into a blockquote holding an inline
`image` (or nothing), which violates the blockquote's `block+` content
model. ProseMirror builds the initial document via `nodeFromJSON`, which
does not validate content, so the invalid doc loads silently — then the
first edit transaction that touches the blockquote calls `contentMatchAt`
on it and throws ("Called contentMatchAt on a node with invalid
content"). The viewer's React panel boundary caught the throw and
rendered a crash instead of the file.

Normalize GitHubAlertBlockquote's parsed children to valid `block+`
content (wrap loose inline runs in a paragraph; guarantee at least one
block), so the parsed document is always schema-valid. Round-trip stays
byte-faithful (`> ![x](img)` re-serialises from the wrapping paragraph).

Co-authored-by: Isaac
2026-07-06 16:13:54 +08:00
Pat Sukprasert dfde90dc2f ci(codex-parity): cache the sidecar binary and skip recompiles (#2016)
The codex-parity sidecar source is frozen (one commit ever) with
rev-pinned deps, yet every CI run recompiled all 73 crates (~3 min)
because the old cache stored the target dir, which restored as a hit
but still forced a full rebuild.

Cache the built binary keyed on sidecar/** + rustc version instead,
and skip `cargo build` on a hit. Warm runs drop from ~4 min to ~15s;
the key self-invalidates when the source, Cargo.lock, or toolchain
changes.

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-06 07:52:39 +00:00
Tomu Hirata 5315349c83 fix(members): show friendly message in single-user/header mode (#2013)
* fix(members): show friendly message in single-user/header mode instead of auth error

In plain header mode (no accounts, no OIDC), the /auth/users endpoint
does not exist, causing the Members page to show a misleading error.
Add an early return after all hooks when accounts_enabled is false and
login_url is null, rendering a "not available in single-user mode" message.

* fix(members): skip fetch and show not-available message in single-user mode

- Derive isSingleUser from server_version (non-null on a live server,
  null on the _OFF probe-failure sentinel) to distinguish real
  single-user header mode from a transient /v1/info failure.
- Gate the useEffect on isSingleUser so the identity probe and
  /auth/users fetch are skipped entirely in that mode.
- Add a test case asserting the message renders and listUsers is
  never called; update mock to expose login_url + server_version
  so OIDC and single-user cases are distinguishable.
2026-07-06 07:38:14 +00:00
Pat Sukprasert b3e220ba97 fix(harness-bench): classify full-server token-provisioning failures + document transport coverage (#1994)
* fix(harness-bench): classify token-provisioning failures as infra skips

A full-server run over the SDK harnesses exposed a false-drift: codex and pi
fail basic_turn on that transport with a provider/gateway token-provisioning
error ("provider auth command `sh` produced an empty token"; "could not fetch
a gateway token"), which infra_failure_reason did not recognize — so the turn
read as UNSUPPORTED and drifted (!!✓>✗) against the SUPPORTED declaration.

That is an environment/auth gap in the full-server driver's spawn path, not a
capability the harness lacks. Add the token-provisioning phrasings to the infra
markers (with a dedicated skip reason), so such a failure is reported SKIPPED —
matching how a 403 / connectivity error is already handled — instead of a false
capability drift. claude-sdk on full-server is unaffected: it completes the
full matrix (Tool calling + Policy DENY both SUPPORTED and enforced).

Extends the infra-classification test with the codex/pi token-provisioning
messages. Offline 50 passed / 14 skipped, ruff clean.

* docs(harness-bench): document which transport exercises Tool calling / Policy DENY

A default `--profile oss` run shows `·` for Tool calling and Policy DENY, which
reads as "untested" but is really a transport limitation: those two dimensions
only get a real verdict on `full-server` (sdk-inproc harnesses dispatch tools
internally; native-tui isn't wired for them yet). Add a transport-vs-dimension
coverage table, the `--transport full-server` recipe, and the live-verified
result (claude-sdk: Tool calling ✓, Policy DENY ✓ enforced). Record the codex/pi
full-server gateway-auth gap and the native-tui tool/policy gap as open items.

* fix(harness-bench): accurate skip message for a native harness on full-server

Under --transport full-server, a native profile was rejected with "transport
'native-tui' not supported by the 'sdk-inproc' driver" — misleading, since it
is the full-server driver rejecting it and the fix is to use native-tui.
FullServerDriver.unavailable now rejects native profiles itself with an
accurate message ("... is a native-tui harness; ... use --transport
native-tui") and only borrows the SDK driver's CLI gate, not its
sdk-inproc-specific transport check.

Add a test asserting the message names native-tui and never sdk-inproc.

Context: verified on the oss profile that all four SDK harnesses (claude-sdk,
codex, pi, openai-agents) complete the full matrix on full-server with Tool
calling and Policy DENY both SUPPORTED and enforced. The codex "timeout" seen
earlier was a transient cold-start flake under sequential load (codex completes
a basic turn in ~15s solo), not a hang and not an auth failure once the local
Databricks profile was re-authed — no code change needed for it.

Offline 52 passed / 14 skipped, ruff clean.
2026-07-06 15:36:31 +08:00
Tomu Hirata e8313ac5d0 fix(server): signal SSE streams to exit on shutdown, reduce graceful timeout (#1998)
* fix(server): signal SSE streams to exit on shutdown, reduce graceful timeout

Ctrl-C would hang for up to 30 s because open SSE session streams waited
for their next heartbeat (15 s cadence) before discovering the server was
going away.  After the timeout, uvicorn force-cancelled them, producing
spurious "Exception in ASGI application / CancelledError: timeout graceful
shutdown exceeded" tracebacks.

Fix by broadcasting the end-of-stream sentinel to every subscriber queue
in the lifespan shutdown handler (session_stream.shutdown_all()), so SSE
generators return cleanly without waiting for a heartbeat tick.  The
graceful-shutdown window is also reduced from 30 s to 5 s: SSE connections
now drain on their own; the remaining window is sized for WebSocket tunnel
teardown, which is fast.

* fix(ci): drop labeled/unlabeled from e2e.yml to prevent automerge label from canceling running E2E

label events share the PR-number concurrency key, so applying automerge
mid-run triggered a new workflow run that immediately canceled the
in-progress suite (cancel-in-progress: true), leaving no E2E result.

e2e-ui.yml and integration.yml already removed these trigger types for the
same reason. Remove labeled/unlabeled from e2e.yml and drop the now-
unnecessary gate `if: github.event.label.name != 'automerge'` condition.

* Revert "fix(ci): drop labeled/unlabeled from e2e.yml to prevent automerge label from canceling running E2E"

This reverts commit f198528373.

* fix(server): move shutdown_all() into Server.shutdown override before graceful wait

The lifespan finally block runs AFTER uvicorn's graceful-shutdown timer
has already expired and force-cancelled in-flight tasks, so calling
shutdown_all() there was a no-op.

Move the call into a uvicorn.Server subclass (_ShutdownSignalingServer)
that overrides shutdown(): the sentinel is broadcast to all SSE subscriber
queues before asyncio.wait_for(_wait_tasks_to_complete(), ...) starts, so
generators exit cleanly within the graceful window instead of being
force-cancelled.

Also clean up session_stream.shutdown_all(): remove the contextlib.suppress
guard (queues are unbounded asyncio.Queue(), so QueueFull is unreachable).

* fix(ci): drop labeled/unlabeled from e2e.yml to stop automerge label canceling running E2E

Applying the automerge label mid-run triggered a new workflow run sharing
the same PR-number concurrency key. With cancel-in-progress: true, that
killed the running suite, leaving no E2E result on the PR.

e2e-ui.yml and integration.yml already removed labeled/unlabeled for the
same reason. Remove them from e2e.yml and drop the now-dead gate condition
`if: github.event.label.name != 'automerge'`.

* fix(server): yield event-loop turn after shutdown_all() before closing transports

Without this pause, generators receive _DONE but cannot run until
super().shutdown() calls connection.shutdown()/transport.close() — at
which point they try to flush "data: [DONE]\n\n" to an already-closing
transport.  Writing to a closing transport leaves connections open past
the graceful window, which prevents clear_local_server_record() from
running and leaves the port bound.

One asyncio.sleep(0) turn lets generators consume _DONE, flush their
final chunk, and exit before the transports are torn down.

* fix(server): catch KeyboardInterrupt, use SO_REUSEADDR in port probe

Two issues introduced by the faster shutdown:

1. KeyboardInterrupt now propagates from Server.run() to Click (since we
   dropped the uvicorn.run() wrapper that swallowed it), printing
   "Aborted!" and exiting non-zero.  Add except KeyboardInterrupt: pass
   to match uvicorn.run()'s original behaviour.

2. pick_local_port() probed with a plain socket (no SO_REUSEADDR), which
   fails on macOS/BSD when recently closed connections are still in
   TIME_WAIT with local address 127.0.0.1:6767.  The server's listening
   socket is already gone, and uvicorn would bind fine (it uses
   SO_REUSEADDR), so the probe socket must match.

* revert unrelated e2e.yml change from branch history

* test(cli): update server tests to mock uvicorn.server.Server.run instead of uvicorn.run

The server command now uses uvicorn.Config + _ShutdownSignalingServer(config).run()
rather than uvicorn.run(), so the four tests that monkeypatched uvicorn.run to skip
the blocking server loop were no longer intercepting anything — the real Server.run()
was called, binding to the test port and hanging.

Switch to patching uvicorn.server.Server.run (which _ShutdownSignalingServer inherits)
and capture the same kwarg fields via self.config attributes.
2026-07-06 07:28:28 +00:00
Daniel Lok 5508060e99 feat(doc-sync): label site PRs with release version and assign reviewer (#2002)
Staged omnigent-site doc PRs all target the per-minor X.Y-docs branch and
carried only the automated-docs label, so maintainers couldn't filter them
by the release they'll ship in. Derive vX.Y.Z from omnigent/version.py in
the existing "Resolve docs branch" step and apply it as a label on both the
create and update paths (backfilling PRs opened before the label existed).

Also add the resolved reviewer as an assignee alongside the review request,
so the PR is filterable by assignee from the site's PR list. The two calls
are independent and best-effort — GitHub rejects non-collaborators with 422,
which stays tolerated as before.

Co-authored-by: Isaac
2026-07-06 14:56:15 +08:00
Tomu Hirata 2a1d793815 fix(ci): isolate label-event concurrency in e2e.yml to prevent automerge canceling running suite (#2011)
Label events share the same PR-number concurrency key as code-push events.
With cancel-in-progress: true, applying automerge mid-run fired a new
workflow run that immediately killed the in-progress E2E suite.

Two-part fix:
- Append the label name to the concurrency key for label events (other
  events get the suffix '-run'), so each label gets its own isolated slot
  and can never preempt a synchronize/push run.
- Add an if: on the gate job to short-circuit for label events that are not
  skip-security-scan (e.g. automerge): those runs exit immediately in their
  isolated slot rather than spinning up the full suite.

labeled/unlabeled stay in the trigger: they are the fallback recovery path
for skip-security-scan (rerun-security-gate-run.yml calls this out on line 105).
2026-07-06 06:54:19 +00:00
Tomu Hirata e5bd7cc0f3 fix(triage): prioritise load over LLM rank when assigning issues and PR reviewers (#1996)
* fix(triage): prioritise load over LLM rank when assigning issues and PR reviewers

LLM rank was the primary sort key, so the first owner listed in areas.json
always won even when their open-issue/review load was far higher than other
eligible owners. Swap to (load, rank, login) so load is the primary signal
and LLM rank only breaks ties within the same load bucket.

* test(triage): update cases 17-19 and stale comment for load-primary sort order

Cases 17-19 previously asserted rank-primary / load-secondary behaviour.
Update them (and their descriptions) to reflect the new load-primary ordering.
Also fix a stale block comment in issue-triage.yml that still said
"rank primary, load secondary".

* ci: re-trigger E2E (previous run canceled by automerge label event)
2026-07-06 14:49:50 +09:00
Serena Ruan 427c3b4441 fix(claude-native): stop false "terminal not ready" on mid-turn inject (#2001)
Injecting a web-UI message while Claude Code is mid-turn grows the footer
with running-state rows (a ○ Explore subagent line, extra spinners) that
push the ❯ input glyph to the 6th non-empty line from the bottom — one
past the readiness gate's 5-line scan window. The gate then times out and
the web UI renders a spurious "did not become ready" runtime-error card,
even though the terminal is healthy and the prompt is on screen.

Widening the window alone would resurrect the scrollback false positive
(an echoed ❯ sits at the same depth). Distinguish them structurally: the
live input box always renders a ──── box rule directly below ❯, which a
scrollback echo never has. Keep the 5-line fast path, and additionally
trust a glyph in a wider 8-line window only when a box rule sits below it.

Co-authored-by: Isaac
2026-07-06 13:46:34 +08:00
Daniel Lok 6e8fc19663 Update CHANGELOG for version 0.4.0 release (#2000)
Added release notes for version 0.4.0.
2026-07-06 13:33:11 +08:00
Serena Ruan 9125532066 docs: add client-side queue + steer design (#1999)
Design for a client-side message queue (edit / delete / steer / reorder)
before POST, with auto-flush-on-idle and per-harness steer semantics for
both SDK and native harnesses.

Co-authored-by: Isaac
2026-07-06 13:17:45 +08:00
583 changed files with 61287 additions and 13162 deletions
+1
View File
@@ -21,3 +21,4 @@ shivam5
TomeHirata
xq-yin
hzub
zhengwin
+31 -6
View File
@@ -91,7 +91,9 @@ prompt: |
Read `DIFF_FILE` (with `sys_os_read`) carefully — it is your source of truth.
Pull exact facts (flags, defaults, harness ids, CLI names, config keys) from the
diff itself. Never invent a fact; if the diff doesn't settle something a doc must
state, flag it for manual review rather than guessing.
state, flag it for manual review rather than guessing. Note whether the PR
**adds**, **changes**, or **removes/deprecates** a user-facing feature — that
decides whether you add, edit, or delete docs (Step 3).
## Step 2 — Inspect the live site and decide placement
This is why you have the whole site checked out. Read
@@ -116,7 +118,23 @@ prompt: |
## Step 3 — Write the edit (scoped, grounded, in-style)
Make the change. Editing an existing `page.mdx` in place is best when one fits;
otherwise create the new page and wire it into the nav. Keep the change scoped
to what this PR introduced. Be accurate and concise — no marketing fluff.
to what this PR introduced, changed, or removed. Be accurate and concise — no
marketing fluff.
When the PR **removes or deprecates** a user-facing feature, the docs must
shrink to match — treat this as first-class as adding docs, never as a no-op:
- **Feature removed**: delete the now-untrue content. If a whole page documented
only that feature, delete the `page.mdx` (with `sys_os_shell` `git rm`) AND
remove its entry from the `SECTIONS` array in
`components/DocsSidebarFull.js`. If it was one section of a larger page, cut
that section and any references, table rows, or links pointing at it. Leave
no dangling nav entry or cross-link to a page you deleted.
- **Feature deprecated (not yet gone)**: keep the page but mark it deprecated in
the site's usual style and state the replacement/removal timeline if the diff
gives one; don't delete prematurely.
Ground the removal in the diff: only delete docs for what the PR actually
removed. If you're unsure whether a doc references the removed feature elsewhere
on the site, flag it under "Manual review needed" rather than guessing.
Match the site's conventions by mirroring a real file:
- **Existing page**: preserve its `pageMeta(...)` frontmatter and JSX component
@@ -141,10 +159,17 @@ prompt: |
the affected `<img>` (MDX supports JSX comments; the build is unaffected).
## Output contract (your final assistant text)
After a line containing exactly `<!-- DOC_DRAFT_SUMMARY -->`, emit:
- `## Changes documented` — one bullet per file you created or edited (pages and
`components/DocsSidebarFull.js`): `path — what changed`. If you made no edits,
write `_No edits made._` and explain under the next section.
On the line IMMEDIATELY BEFORE `<!-- DOC_DRAFT_SUMMARY -->`, emit a single
`DOC_PR_TITLE:` line — a concise, imperative summary of what the docs now cover,
grounded in the diff (e.g. `DOC_PR_TITLE: document SMALLINT enum-column storage`).
Keep it under 60 characters, no trailing period, and do NOT prefix it with
`docs:` (the workflow adds that). This becomes the docs PR title.
Then, after a line containing exactly `<!-- DOC_DRAFT_SUMMARY -->`, emit:
- `## Changes documented` — one bullet per file you created, edited, or deleted
(pages and `components/DocsSidebarFull.js`): `path — what changed` (say
"deleted" / "removed section" for removals). If you made no edits, write
`_No edits made._` and explain under the next section.
- `## Manual review needed` — a checklist: `- [ ] <doc path or area> — <why>`.
Use this for things you genuinely cannot do well: stale screenshots/GIFs (you
can't regenerate binaries), or a placement decision you're truly unsure about.
+7
View File
@@ -8,6 +8,7 @@
REQUIRED=(
"Pre-commit checks"
"Docker build"
"Pytest (runtime-harnesses)"
"Pytest (runtime-policies)"
"Pytest (runtime-core)"
@@ -20,6 +21,8 @@ REQUIRED=(
"Pytest (server-responses)"
"Pytest (server-rest)"
"Pytest (spec-llms)"
"Pytest (runner-app)"
"Pytest (stores)"
"Pytest (misc)"
"Pytest (databricks)"
"E2E Tests (shard 0/4)"
@@ -35,6 +38,7 @@ REQUIRED=(
)
ALLOW_SKIP=(
"Docker build"
"Pytest (runtime-harnesses)"
"Pytest (runtime-policies)"
"Pytest (runtime-core)"
@@ -47,6 +51,8 @@ ALLOW_SKIP=(
"Pytest (server-responses)"
"Pytest (server-rest)"
"Pytest (spec-llms)"
"Pytest (runner-app)"
"Pytest (stores)"
"Pytest (misc)"
"Pytest (databricks)"
"E2E Tests (shard 0/4)"
@@ -69,6 +75,7 @@ is_allow_skip() { printf '%s\n' "${ALLOW_SKIP[@]}" | grep -qxF "$1"; }
# workflow is still queued or re-running.
workflow_for() {
case "$1" in
"Docker build") echo "Docker build" ;;
"Pytest ("*) echo "CI" ;;
"E2E Tests (shard "*) echo "E2E Tests" ;;
"E2E UI Tests (shard "*) echo "E2E UI Tests" ;;
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Daily Discord-watch rotation reminder.
Picks the person on watch for the current day and pings them in Slack on the
morning of *their* local timezone. The rotation is deterministic — the
assignee is a function of the date and the person's position in the list — so
there is no state to store anywhere.
The GitHub Actions workflow wakes at a couple of fixed UTC times (one per
timezone's morning). On each run the day's assignee is pinged only if it's
currently morning where they live; if not, the run for their timezone's
morning handles them. Our timezones are far enough apart that only one is ever
in its morning at a time, so at most one person is pinged per run.
Set SLACK_WEBHOOK_URL to post for real. Leave it unset for a dry run that just
prints what it would do — handy for testing the rotation order without Slack.
"""
from __future__ import annotations
import datetime
import json
import os
import urllib.error
import urllib.request
from dataclasses import dataclass
from zoneinfo import ZoneInfo
# Each cron run is one timezone's morning scan: we ping today's assignee only
# if it's currently morning where they are. A run that's morning in SF is night
# in Singapore and vice versa, so at most one timezone matches per run. Morning
# is a band rather than an exact hour, which absorbs both daylight saving and
# GitHub's frequently-delayed cron schedule — a run that fires a few hours late
# still counts as that person's morning. The band starts at 05:00 (not
# midnight) so a delayed *other* timezone's cron spilling past local midnight
# isn't mistaken for this timezone's morning, which would double-ping.
MORNING_START_HOUR = 5
MORNING_END_HOUR = 12
# Skip Saturdays and Sundays (in each person's local time). The rotation also
# advances by workdays only, so Friday hands off straight to Monday.
WEEKDAYS_ONLY = True
# Rotation anchor: workday 0 is this date. Any Monday works; it only sets the
# phase of the cycle, not who is in it.
EPOCH = datetime.date(2026, 1, 5) # a Monday
@dataclass(frozen=True)
class Person:
name: str # for logs / dry-run output only
slack_id: str # Slack member ID, e.g. "U01ABC2DEF" (NOT the display name)
tz: str # IANA timezone name, e.g. "America/Los_Angeles"
# Out-of-office spans as inclusive (start, end) ISO date pairs, e.g.
# (("2026-07-13", "2026-07-17"),). On any OOO day the person is skipped and
# the next available person covers; the OOO person keeps their later slots.
ooo: tuple[tuple[str, str], ...] = ()
# Rotation order. Slack member IDs (profile -> ⋮ More -> Copy member ID) and
# each person's IANA timezone.
PEOPLE: list[Person] = [
Person("Aravind Segu", "U01A12R8NUR", "America/Los_Angeles"),
Person("Bryan Qiu", "U05KA5T983Y", "America/Los_Angeles"),
Person("Daniel Lok", "U060CNWNHSQ", "Asia/Singapore"),
Person("Dhruv Gupta", "U0A76097E1F", "America/Los_Angeles"),
Person("Edwin He", "U077B1V6WQJ", "America/Los_Angeles"),
Person("Pat Sukprasert", "U05HRKWFY81", "Asia/Singapore"),
Person("Sabhya Chhabria", "U07A1KQDXAB", "America/Los_Angeles"),
Person("Serena Ruan", "U0571L5KNLR", "Asia/Singapore"),
Person("Shivam Mittal", "U09FZKX9S6B", "America/Los_Angeles"),
Person("Tomu Hirata", "U07TX4PR5MZ", "Asia/Singapore"),
Person("Zeyi (Rice) Fan", "U09L5HT4CH0", "America/Los_Angeles"),
]
def _workdays_between(start: datetime.date, end: datetime.date) -> int:
"""Number of MonFri days in [start, end). Negative if end precedes start."""
if end < start:
return -_workdays_between(end, start)
full_weeks, extra = divmod((end - start).days, 7)
count = full_weeks * 5
for i in range(extra):
if (start + datetime.timedelta(days=full_weeks * 7 + i)).weekday() < 5:
count += 1
return count
def is_ooo(person: Person, local_date: datetime.date) -> bool:
"""Whether person is out of office on local_date (inclusive spans)."""
for start, end in person.ooo:
if datetime.date.fromisoformat(start) <= local_date <= datetime.date.fromisoformat(end):
return True
return False
def assignee_for(local_date: datetime.date) -> Person | None:
"""The person on watch for a given local workday, or None if all are OOO.
Indexed by the number of workdays since EPOCH (which is itself a Monday),
so weekends advance nobody and Friday hands off directly to Monday. If the
slot's person is OOO, the next available person covers — probing forward so
coverage stays a pure function of the date (no stored state). Only
meaningful for weekdays; weekends are filtered out before this is called.
"""
workday_number = _workdays_between(EPOCH, local_date)
for offset in range(len(PEOPLE)):
person = PEOPLE[(workday_number + offset) % len(PEOPLE)]
if not is_ooo(person, local_date):
return person
return None # everyone is OOO that day
def whose_turn_now(now_utc: datetime.datetime) -> Person | None:
"""Return the person to ping right now, or None if it isn't anyone's morning.
Each person is evaluated in their own timezone: it must be a weekday morning
(before noon) there, and today's rotation slot must land on them. Since our
timezones are far enough apart that only one is ever in its morning at a
time, at most one person matches. A person missed by a late/early run is
picked up by the next run that lands in their morning.
"""
for person in PEOPLE:
local = now_utc.astimezone(ZoneInfo(person.tz))
if not (MORNING_START_HOUR <= local.hour < MORNING_END_HOUR):
continue
if WEEKDAYS_ONLY and local.weekday() >= 5: # 5=Sat, 6=Sun
continue
if assignee_for(local.date()) == person:
return person
return None
class SlackPostError(RuntimeError):
"""Raised when the Slack POST fails, without exposing the webhook URL."""
def post_to_slack(webhook_url: str, person: Person) -> None:
text = (
f"<@{person.slack_id}> you're on *Discord watch* today \U0001f440 "
f"— please keep an eye on the channel."
)
payload = json.dumps({"text": text}).encode()
req = urllib.request.Request(
webhook_url,
data=payload,
headers={"Content-Type": "application/json"},
)
# Catch and re-raise without the URL: urllib errors stringify the full
# webhook URL, which must never reach the Actions log or error output.
try:
with urllib.request.urlopen(req, timeout=30) as resp:
resp.read()
except urllib.error.HTTPError as exc:
raise SlackPostError(f"Slack returned HTTP {exc.code} {exc.reason}") from None
except urllib.error.URLError as exc:
raise SlackPostError(f"could not reach Slack: {exc.reason}") from None
def _report_todays_assignees(now_utc: datetime.datetime) -> None:
"""Log who's on watch for each timezone's current local date.
Runs regardless of the morning window so a manual run is always
informative, even outside anyone's ping window.
"""
for tz in sorted({p.tz for p in PEOPLE}):
local = now_utc.astimezone(ZoneInfo(tz))
if local.weekday() >= 5: # 5=Sat, 6=Sun
who = "nobody (weekend)"
else:
person = assignee_for(local.date())
who = person.name if person else "nobody (all OOO)"
print(f" {tz}: {local:%Y-%m-%d %a} -> {who}")
def main() -> None:
now_utc = datetime.datetime.now(datetime.timezone.utc)
print(f"Today's watch by timezone (as of {now_utc:%Y-%m-%d %H:%M UTC}):")
_report_todays_assignees(now_utc)
person = whose_turn_now(now_utc)
if person is None:
print(f"{now_utc:%Y-%m-%d %H:%M UTC}: nobody's on watch right now, nothing to do.")
return
local = now_utc.astimezone(ZoneInfo(person.tz))
webhook_url = os.environ.get("SLACK_WEBHOOK_URL")
if not webhook_url:
print(
f"[dry run] Would ping {person.name} ({person.slack_id}) "
f"— it's {local:%Y-%m-%d %H:%M} in {person.tz}. "
f"Set SLACK_WEBHOOK_URL to post for real."
)
return
post_to_slack(webhook_url, person)
print(f"Pinged {person.name} ({person.slack_id}) at {local:%Y-%m-%d %H:%M %Z}.")
if __name__ == "__main__":
main()
+6 -7
View File
@@ -210,16 +210,15 @@ module.exports = async ({ github, context, core }) => {
}
const loadOf = (u) => load.get(u.toLowerCase()) || 0;
// Helper: take the N most-preferred from a list. Sort key is (rank, load,
// random): LLM area-fit rank first (lower = better; Infinity for unranked, so
// an all-unranked list -- no rank file -- sorts purely by load, i.e. today's
// behavior), then fewest open review requests, then a pre-rolled random value
// to break any remaining same-rank-same-load tie. The `!==` guards avoid
// subtracting two Infinities (which would be NaN).
// Helper: take the N most-preferred from a list. Sort key is (load, rank,
// random): fewest open review requests first so workload stays balanced;
// LLM area-fit rank breaks ties within the same load bucket; a pre-rolled
// random value breaks any remaining tie. The `!==` guards avoid subtracting
// two Infinities (which would be NaN).
const takeLowest = (list, n) => {
const keyed = list.map((u) => ({ u, r: rankOf(u), l: loadOf(u), j: Math.random() }));
keyed.sort((a, b) =>
a.r !== b.r ? a.r - b.r : a.l !== b.l ? a.l - b.l : a.j - b.j
a.l !== b.l ? a.l - b.l : a.r !== b.r ? a.r - b.r : a.j - b.j
);
return keyed.slice(0, n).map((x) => x.u);
};
+12 -14
View File
@@ -285,41 +285,39 @@ function assert(name, cond, detail) {
assert("capped overflow is warned",
r.warnings.some((w) => /capping push-down/.test(w)), JSON.stringify(r.warnings));
// 17. LLM ranking overrides load within the candidate pool: dhruv0811 has the
// lowest load (would win on load alone), but the rank prefers dbczumar, an
// inner owner -- so dbczumar is chosen.
// 17. Load beats LLM rank: dhruv0811 has the lowest load (0) and wins even
// though the rank prefers dbczumar (rank 0 but load 1).
r = await run({
files: ["omnigent/inner/foo.py"],
load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1 },
rank: ["dbczumar", "TomeHirata", "SabhyaC26", "dhruv0811"],
});
assert("LLM rank beats load within the area pool",
JSON.stringify(r.added) === JSON.stringify(["dbczumar"]), JSON.stringify(r));
assert("load beats LLM rank within the area pool",
JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]), JSON.stringify(r));
// 18. Allowlist enforcement: a rank naming someone who does NOT own the touched
// area (PattaraS is a maintainer + pool member, but not an inner owner) is
// ignored for that entry; the ranking only reorders actual candidates, so
// the next ranked inner owner (dbczumar) wins -- never PattaraS.
// ignored; the ranking only reorders actual candidates. Load is primary, so
// dhruv0811 (load 0) wins over dbczumar (load 1) -- never PattaraS.
r = await run({
files: ["omnigent/inner/foo.py"],
load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1, PattaraS: 0 },
rank: ["PattaraS", "dbczumar", "TomeHirata", "SabhyaC26", "dhruv0811"],
});
assert("LLM rank cannot route outside the area owners",
JSON.stringify(r.added) === JSON.stringify(["dbczumar"]) && !r.added.includes("PattaraS"),
JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]) && !r.added.includes("PattaraS"),
JSON.stringify(r));
// 19. Unranked candidates (rank omits them) sort after ranked ones but still by
// load: rank lists only SabhyaC26 (highest load); the rest are unranked, so
// SabhyaC26 -- despite load 5 -- is preferred because a finite rank beats
// Infinity. Confirms the rank-primary / load-secondary ordering.
// 19. Load is primary even when only one candidate is ranked: rank lists only
// SabhyaC26 (load 5); dhruv0811 is unranked but has load 0, so dhruv0811
// wins. Confirms the load-primary / rank-secondary ordering.
r = await run({
files: ["omnigent/inner/foo.py"],
load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1 },
rank: ["SabhyaC26"],
});
assert("a ranked high-load owner beats unranked low-load owners",
JSON.stringify(r.added) === JSON.stringify(["SabhyaC26"]), JSON.stringify(r));
assert("unranked low-load owner beats ranked high-load owner",
JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]), JSON.stringify(r));
// 20. Adoption still overrides the LLM rank: a linked-issue maintainer assignee
// (TomeHirata) is adopted as reviewer even when the rank prefers someone
+156
View File
@@ -0,0 +1,156 @@
name: Benchmark
# Nightly run of the HTTP user-journey performance benchmark
# (dev/benchmarks/omnigent). Seeds a sizeable corpus, boots a real server
# against it, drives the journeys, and uploads the JSON report as an artifact.
# Runs a backend matrix — SQLite (in-process) and Postgres (a service
# container, matching prod's Lakebase/Postgres round-trip + pooling profile).
# A workspace Databricks notebook pulls these artifacts via the GitHub API into
# a Delta table for the trend dashboard (see dev/benchmarks/omnigent/README.md)
# — so this workflow only produces artifacts; it never touches Databricks.
#
# Scheduled -> runs on the trusted default branch with the repo GITHUB_TOKEN;
# it reads no PR-authored code. Also dispatchable for an ad-hoc run.
on:
schedule:
- cron: "37 7 * * *" # 07:37 UTC nightly (off-peak, off the :00 mark)
workflow_dispatch:
inputs:
iterations:
description: "Requests per run"
required: false
default: "100"
runs:
description: "Timed runs per journey"
required: false
default: "3"
sessions:
description: "Seeded sessions"
required: false
default: "5000"
items_per_session:
description: "Seeded items per session"
required: false
default: "200"
permissions:
contents: read
env:
# No web SPA build during `uv sync` (setup.py _build_web_ui): this job never
# serves the bundle, and the build otherwise times out on public npm.
OMNIGENT_SKIP_WEB_UI: "true"
UV_INDEX_URL: https://pypi.org/simple
PIP_INDEX_URL: https://pypi.org/simple
ITERATIONS: ${{ github.event_name == 'workflow_dispatch' && inputs.iterations || '100' }}
RUNS: ${{ github.event_name == 'workflow_dispatch' && inputs.runs || '3' }}
SESSIONS: ${{ github.event_name == 'workflow_dispatch' && inputs.sessions || '5000' }}
ITEMS: ${{ github.event_name == 'workflow_dispatch' && inputs.items_per_session || '200' }}
concurrency:
# Never cancel a scheduled run mid-flight (each is a distinct data point);
# coalesce manual dispatches per ref.
group: benchmark-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' }}
jobs:
benchmark:
name: Run benchmark (${{ matrix.backend }})
if: github.repository == 'omnigent-ai/omnigent'
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
backend: [sqlite, postgres]
services:
# A Postgres service is defined unconditionally (GitHub Actions has no
# per-matrix-value service gating), but only the postgres leg connects to
# it — the sqlite leg simply ignores it. postgres:16 mirrors Lakebase's
# major version.
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: bench
POSTGRES_DB: benchdb
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- name: Check out repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
- name: Set up uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v3
with:
enable-cache: true
- name: Install dependencies
# `databricks` extra carries psycopg[binary] for the Postgres backend.
run: uv sync --extra dev --extra databricks
# Resolve the DB URI + a stable seed-cache key for this backend. The
# cache key binds the DB schema head + seed.py contents + corpus config,
# so a schema change or seed edit busts the cache and forces a reseed —
# the "you changed the schema, refresh the seed" contract (SQLite only;
# the Postgres service is fresh each run so its DB is never cached).
- name: Resolve DB target
id: db
run: |
HEAD="$(uv run --no-sync dev/benchmarks/omnigent/seed.py --print-head)"
if [[ "${{ matrix.backend }}" == "postgres" ]]; then
echo "uri=postgresql+psycopg://postgres:bench@localhost:5432/benchdb" >> "$GITHUB_OUTPUT"
echo "cache_path=" >> "$GITHUB_OUTPUT"
else
echo "uri=sqlite:///$PWD/bench.db" >> "$GITHUB_OUTPUT"
echo "cache_path=bench.db" >> "$GITHUB_OUTPUT"
fi
echo "cache_key=benchdb-${{ matrix.backend }}-$HEAD-${SESSIONS}x${ITEMS}-${{ hashFiles('dev/benchmarks/omnigent/seed.py') }}" >> "$GITHUB_OUTPUT"
# Reuse a previously-seeded SQLite corpus when schema + seed + config are
# unchanged. No-op for the postgres leg (empty path).
- name: Restore seeded SQLite corpus
if: matrix.backend == 'sqlite'
id: seedcache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
with:
path: ${{ steps.db.outputs.cache_path }}
key: ${{ steps.db.outputs.cache_key }}
- name: Seed corpus
# Postgres always seeds (fresh service each run); SQLite seeds only on a
# cache miss. seed.py is itself idempotent, so a stray hit is harmless.
if: matrix.backend == 'postgres' || steps.seedcache.outputs.cache-hit != 'true'
run: |
uv run --no-sync dev/benchmarks/omnigent/seed.py \
--database-uri "${{ steps.db.outputs.uri }}" \
--sessions "$SESSIONS" --items-per-session "$ITEMS"
- name: Run benchmark
run: |
uv run --no-sync dev/benchmarks/omnigent/run.py \
--database-uri "${{ steps.db.outputs.uri }}" \
--iterations "$ITERATIONS" \
--runs "$RUNS" \
--output "benchmark-results-${{ matrix.backend }}.json"
- name: Upload benchmark results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: benchmark-results-${{ matrix.backend }}-${{ github.run_id }}
path: benchmark-results-${{ matrix.backend }}.json
retention-days: 90
if-no-files-found: warn
+129 -7
View File
@@ -2,10 +2,11 @@ name: CI
# Unit-test pytest matrix on every non-draft PR and on push to main. Tests are
# split across directory-based matrix groups (runtime-*, server-*, inner-rest,
# tools, repl-sdk, spec-llms, misc) so slow files don't bottleneck one runner;
# the slowest groups use `--dist=worksteal` to fan tests out within a file. The
# `misc` group is a catch-all so new top-level tests/<dir>/ are picked up
# automatically. Draft PRs are skipped (ready_for_review re-fires the workflow).
# tools, repl-sdk, spec-llms, runner-app, stores, misc) so slow files don't
# bottleneck one runner; the slowest groups use `--dist=worksteal` to fan tests
# out within a file. The `misc` group is a catch-all so new top-level
# tests/<dir>/ are picked up automatically (it ignores the dirs that have their
# own group). Draft PRs are skipped (ready_for_review re-fires the workflow).
# A `coverage-report` job combines per-shard coverage for code-coverage.yml.
on:
@@ -94,7 +95,21 @@ jobs:
- group: integration-mock
paths: tests/integration
workers: "0"
# Carved out of misc: runner + stores were ~68% of misc's cpu and
# under loadfile a single 500s+ file (test_app_sessions_native) pinned
# one worker and set the whole misc wall time. worksteal fans each
# dir's tests across workers (biggest single test is ~40s / ~5s, so
# the floor drops from ~500s to ~100s). Both dirs' conftests are
# function-scoped, so splitting a file across workers is safe.
- group: runner-app
paths: tests/runner
dist: worksteal
- group: stores
paths: tests/stores
dist: worksteal
# Catch-all so new top-level tests/<dir>/ are covered automatically.
# worksteal keeps the biggest remaining file (the benchmark smoke
# test, ~58s) from re-pinning one worker as this catch-all grows.
- group: misc
paths: >-
tests
@@ -112,6 +127,9 @@ jobs:
--ignore=tests/spec
--ignore=tests/llms
--ignore=tests/codex_parity
--ignore=tests/runner
--ignore=tests/stores
dist: worksteal
# Databricks-coupled tests (Lakebase token engine, psycopg). This is
# the only lane that installs the `databricks` extra; the
# @pytest.mark.databricks marker keeps these tests off the lean lanes
@@ -204,6 +222,100 @@ jobs:
retention-days: 14
include-hidden-files: true # the per-shard .coverage.<group> dotfile
stores-postgres:
name: Pytest (stores-postgres)
needs: gate
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: omnigent
POSTGRES_DB: omnigent_root
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
with:
python-version-file: ".python-version"
- uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a
with:
enable-cache: true
- name: Install dependencies
run: uv sync --locked --extra all --extra dev --extra databricks
- name: Run store + DB tests against PostgreSQL
env:
OMNIGENT_TEST_DB_URI: postgresql+psycopg://postgres:omnigent@localhost:5432/omnigent_root
run: |
uv run pytest tests/stores tests/db \
-m "not databricks" \
-n 4 \
--dist=loadfile \
--timeout=300 \
--junitxml=artifacts/pytest-stores-postgres.xml
- if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: pytest-stores-postgres-${{ github.run_id }}
path: artifacts/
retention-days: 14
stores-mysql:
name: Pytest (stores-mysql)
needs: gate
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-latest
timeout-minutes: 30
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: omnigent
MYSQL_DATABASE: omnigent_root
ports:
- 3306:3306
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1 -u root -pomnigent"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
with:
python-version-file: ".python-version"
- uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a
with:
enable-cache: true
- name: Install system MySQL client library
run: sudo apt-get update -qq && sudo apt-get install -y -q libmysqlclient-dev
- name: Install dependencies
run: uv sync --locked --extra all --extra dev --extra databricks && uv pip install mysqlclient
- name: Run store + DB tests against MySQL
env:
OMNIGENT_TEST_DB_URI: mysql+mysqldb://root:omnigent@127.0.0.1:3306/omnigent_root
run: |
uv run pytest tests/stores tests/db \
-m "not databricks" \
-n 4 \
--dist=loadfile \
--timeout=300 \
--junitxml=artifacts/pytest-stores-mysql.xml
- if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: pytest-stores-mysql-${{ github.run_id }}
path: artifacts/
retention-days: 14
codex-parity:
name: Pytest (codex-parity)
needs: gate
@@ -229,11 +341,20 @@ jobs:
with:
toolchain: stable
- name: Cache Rust build
- name: Capture Rust version
id: rustc
run: echo "version=$(rustc --version | tr ' ' '-')" >> "$GITHUB_OUTPUT"
# The sidecar source is frozen and its deps are rev-pinned, so the binary is
# a pure function of sidecar/** + the toolchain. Cache the built binary (not
# the 1.6 GB target dir) and skip the ~3 min compile below on a hit; the key
# self-invalidates when the source, Cargo.lock, or rustc changes.
- name: Cache parity sidecar binary
id: sidecar-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
with:
path: .tmp-codex-parity-target
key: codex-parity-sidecar-${{ runner.os }}-${{ hashFiles('tests/codex_parity/sidecar/Cargo.lock') }}
path: .tmp-codex-parity-target/debug/codex-parity-sidecar
key: codex-parity-bin-${{ runner.os }}-${{ steps.rustc.outputs.version }}-${{ hashFiles('tests/codex_parity/sidecar/**') }}
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -255,6 +376,7 @@ jobs:
run: uv sync --locked --extra all --extra dev
- name: Build parity sidecar
if: steps.sidecar-cache.outputs.cache-hit != 'true'
run: |
cargo build \
--manifest-path tests/codex_parity/sidecar/Cargo.toml \
@@ -0,0 +1,32 @@
name: Discord watch rotation
# Wakes up only at the UTC times that are ~08:00 in an assignee's timezone.
# Note: a single fixed UTC time can't track San Francisco's daylight saving,
# so the SF ping lands at 08:00 in summer (PDT) and 07:00 in winter (PST).
on:
schedule:
- cron: "0 0 * * *" # 08:00 Asia/Singapore (UTC+8, no daylight saving)
- cron: "0 15 * * *" # 08:00 SF in summer (PDT); 07:00 in winter (PST)
workflow_dispatch: {} # manual "Run workflow" button for testing
# Only needs to check out the repo; nothing is written back.
permissions:
contents: read
# Avoid overlapping runs if one is slow.
concurrency:
group: discord-watch-rotation
cancel-in-progress: false
jobs:
ping:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12" # for zoneinfo in the stdlib
- name: Send rotation ping
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: python .github/scripts/rotation.py
+62 -21
View File
@@ -201,25 +201,30 @@ jobs:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
# Derive the per-minor docs staging branch from the runtime version. main
# carries X.Y.Z.dev0, so 0.5.0.dev0 → "0.5-docs". All docs for the 0.5 line
# (incl. patches) stage on this one branch until release publishes it.
# Derive the per-minor docs staging branch and the release version from the
# runtime version. main carries X.Y.Z.dev0, so 0.5.0.dev0 → branch "0.5-docs"
# and label "v0.5.0". All docs for the 0.5 line (incl. patches) stage on the
# one branch until release publishes it; the vX.Y.Z label lets maintainers
# filter the staged PRs by the release they'll ship in.
- name: Resolve docs branch
id: docsbranch
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
run: |
set -euo pipefail
minor="$(python3 - <<'PYEOF'
import pathlib, re
python3 - <<'PYEOF'
import os, pathlib, re
text = pathlib.Path("omnigent/version.py").read_text()
m = re.search(r'VERSION\s*=\s*["\']([0-9]+)\.([0-9]+)', text)
m = re.search(r'VERSION\s*=\s*["\']([0-9]+)\.([0-9]+)\.([0-9]+)', text)
if not m:
raise SystemExit("could not parse X.Y from omnigent/version.py")
print(f"{m.group(1)}.{m.group(2)}")
raise SystemExit("could not parse X.Y.Z from omnigent/version.py")
major, minor, patch = m.groups()
branch = f"{major}.{minor}-docs"
version = f"v{major}.{minor}.{patch}"
with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
fh.write(f"branch={branch}\n")
fh.write(f"version={version}\n")
print(f"::notice::Docs stage on branch {branch} (release {version})")
PYEOF
)"
echo "branch=${minor}-docs" >> "$GITHUB_OUTPUT"
echo "::notice::Docs stage on branch ${minor}-docs"
- name: Set up Python
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
@@ -593,6 +598,22 @@ jobs:
m = re.search(r"<!--\s*DOC_DRAFT_SUMMARY\s*-->", raw)
summary = raw[m.end():].strip() if m else "_(drafter produced edits but no summary)_"
# Title the docs PR after the DOCS change, not the source PR number (which
# already appears in the body). Prefer the drafter's DOC_PR_TITLE line; fall
# back to the source PR title, then to the old "document #N" form. LLM output
# is untrusted, so sanitize: first line only, strip control chars, collapse
# whitespace, drop a stray leading "docs:" (added below), and cap length.
mt = re.search(r"^\s*DOC_PR_TITLE:\s*(.+?)\s*$", raw, re.MULTILINE)
# Collapse whitespace (incl. tabs) to single spaces FIRST, so a stray tab
# separates words rather than being stripped and joining them, then drop
# any remaining non-whitespace control chars.
draft_title = re.sub(r"\s+", " ", mt.group(1) if mt else "").strip()
draft_title = re.sub(r"[\x00-\x1f\x7f]", "", draft_title)
draft_title = re.sub(r"^docs:\s*", "", draft_title, flags=re.IGNORECASE).strip()[:60].strip()
pr_title = f"docs: {draft_title or title or f'document {code}#{pr}'}"
pathlib.Path("/tmp/site_pr_title.txt").write_text(pr_title)
print(f"pr_title={pr_title!r}")
# Tag the maintainer who MERGED the PR — the author may be an outside
# contributor with no site access, but a maintainer always merges. Fall back
# to the author when there's no usable merger (e.g. a manual run on an
@@ -635,9 +656,14 @@ jobs:
PR_NUMBER: ${{ steps.plan.outputs.pr }}
REVIEWER: ${{ steps.sitepr.outputs.reviewer }}
DOCS_BRANCH: ${{ steps.docsbranch.outputs.branch }}
VERSION_LABEL: ${{ steps.docsbranch.outputs.version }}
run: |
set -euo pipefail
BRANCH="auto/docs/pr-${PR_NUMBER}"
# Descriptive PR/commit title from the sitepr step (drafter's DOC_PR_TITLE,
# else the source PR title, else "docs: document #N"). The PR number lives
# in the body, so it's kept out of the title.
PR_TITLE="$(cat /tmp/site_pr_title.txt)"
git config user.name "omnigent-ci[bot]"
git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com"
# Credentials are NOT persisted in .git/config (so the unsandboxed drafter
@@ -680,22 +706,33 @@ jobs:
git checkout -B "$BRANCH"
git add -A
git commit -m "docs: document ${CODE_REPO}#${PR_NUMBER}"
git commit -m "$PR_TITLE"
# --force is safe here: the guard above ensured the branch carries only
# bot commits.
git push --force "$PUSH_URL" "$BRANCH"
# The vX.Y.Z label marks which release the staged docs will ship in, so
# maintainers can filter the site PRs by release. Ensure it exists (with
# automated-docs) before applying it below.
gh label create automated-docs --repo "$SITE_REPO_SLUG" --color 0E8A16 \
--description "Automated documentation update" 2>/dev/null || true
gh label create "$VERSION_LABEL" --repo "$SITE_REPO_SLUG" --color FBCA04 \
--description "Docs staged for the ${VERSION_LABEL} release" 2>/dev/null || true
EXISTING="$(gh pr list --repo "$SITE_REPO_SLUG" --head "$BRANCH" --state open \
--json number --jq '.[0].number // empty' 2>/dev/null || true)"
if [ -n "$EXISTING" ]; then
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --body-file /tmp/site_pr_body.md || true
# --add-label backfills PRs opened before the label existed; it's a no-op
# when already present.
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" \
--title "$PR_TITLE" \
--add-label "automated-docs" --add-label "$VERSION_LABEL" \
--body-file /tmp/site_pr_body.md || true
echo "Updated site PR #$EXISTING."
else
gh label create automated-docs --repo "$SITE_REPO_SLUG" --color 0E8A16 \
--description "Automated documentation update" 2>/dev/null || true
if gh pr create --repo "$SITE_REPO_SLUG" --base "$DOCS_BRANCH" --head "$BRANCH" \
--title "docs: document ${CODE_REPO}#${PR_NUMBER}" \
--label automated-docs --body-file /tmp/site_pr_body.md; then
--title "$PR_TITLE" \
--label automated-docs --label "$VERSION_LABEL" --body-file /tmp/site_pr_body.md; then
EXISTING="$(gh pr list --repo "$SITE_REPO_SLUG" --head "$BRANCH" --state open \
--json number --jq '.[0].number // empty' 2>/dev/null || true)"
echo "Opened site PR for $BRANCH."
@@ -704,13 +741,17 @@ jobs:
fi
fi
# Always attempt the review request, decoupled from PR creation so a
# non-addable reviewer can't fail the open. GitHub returns 422 for users it
# can't add (non-collaborators / concealed org members); tolerate it — the
# reviewer is also @-mentioned in the body as a durable fallback ping.
# Always attempt the review request + assignment, decoupled from PR creation
# so a non-addable reviewer can't fail the open. GitHub returns 422 for users
# it can't add (non-collaborators / concealed org members); tolerate it — the
# reviewer is also @-mentioned in the body as a durable fallback ping. The two
# calls are independent so one failing doesn't skip the other. Assigning makes
# the PR filterable by assignee from the site's PR list.
if [ -n "${REVIEWER}" ] && [ -n "${EXISTING}" ]; then
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --add-reviewer "${REVIEWER}" \
|| echo "::notice::Could not request review from ${REVIEWER} (not addable); they're @-mentioned in the PR body."
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --add-assignee "${REVIEWER}" \
|| echo "::notice::Could not assign ${REVIEWER} (not addable); they're @-mentioned in the PR body."
fi
- name: Note draft skipped (no site token)
+81
View File
@@ -0,0 +1,81 @@
# Build-only Docker check for PRs. Compensates for retiring per-commit main
# publishes (oss-publish-images.yml now builds on tags + nightly only): a broken
# Dockerfile / lockfile / frontend build would otherwise not surface until the
# nightly rebuild or a release. Builds the server image single-arch (linux/amd64)
# with the GHA layer cache and runs a `omnigent --help` CLI smoke. It never pushes.
#
# Scope: the server target exercises the shared builder stage (Python deps +
# web SPA build) that all four published variants inherit, so it catches the
# common breakage without paying for the host/openshell/kubernetes variants or
# the emulated arm64 leg.
#
# Blocking merge-gate check: "Docker build" is in the REQUIRED list in
# .github/scripts/merge-ready/required.sh. Because of the paths filter below it
# can legitimately be absent (a PR touching nothing in the image), so it is also
# in ALLOW_SKIP with a workflow_for() arm, and this workflow's name is in
# merge-ready.yml's workflow_run list so the gate re-evaluates when it completes.
name: Docker build
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
# Only build when something that lands in the image changes. Mirrors the
# publish workflow's former push paths (web/** IS included here — the image
# bakes the SPA, so a web-only PR can still break the build).
paths:
- 'deploy/docker/Dockerfile'
- 'deploy/docker/entrypoint.py'
- 'omnigent/**'
- 'web/**'
- 'sdks/**'
- 'pyproject.toml'
- 'setup.py'
- 'uv.lock'
- 'web/package-lock.json'
- '.github/workflows/docker-build.yml'
permissions:
contents: read
concurrency:
group: docker-build-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
# Security precondition gate (security-gate.yml): untrusted PRs wait for the
# scan before the build runs on their code; trusted authors pass through.
gate:
uses: ./.github/workflows/security-gate.yml
build:
name: Docker build
needs: gate
# Draft PRs skip the build (ready_for_review re-fires the workflow), matching
# the pytest job in ci.yml.
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
# Single-arch (amd64) build, no push. load: true imports the result into
# the runner's Docker so the smoke step below can run it. Shares the same
# type=gha cache the publish workflow writes, so warm PRs reuse layers.
- name: Build server image (amd64, no push)
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: deploy/docker/Dockerfile
push: false
load: true
tags: omnigent-server:pr-${{ github.event.pull_request.number || github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
- name: CLI smoke
run: docker run --rm omnigent-server:pr-${{ github.event.pull_request.number || github.sha }} omnigent --help
+14 -7
View File
@@ -111,16 +111,23 @@ jobs:
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
toolchain: stable
# Pin the toolchain for a stable cache fingerprint, key on the sidecar
# Cargo.lock. A warm hit reuses every dep and only relinks the workspace
# crate (~40s); a cold miss is the full ~7min compile (rare -- the lock
# is near-static). Same key as ci.yml's codex-parity job, so they share.
- name: Cache Rust build
- name: Capture Rust version
id: rustc
run: echo "version=$(rustc --version | tr ' ' '-')" >> "$GITHUB_OUTPUT"
# The sidecar source is frozen and its deps are rev-pinned, so the binary
# is a pure function of sidecar/** + the toolchain. Cache the built binary
# (not the 1.6 GB target dir) and skip the ~7 min compile below on a hit;
# the key self-invalidates when the source, Cargo.lock, or rustc changes.
# Same key as ci.yml's codex-parity job -- ci.yml runs on push to main and
# populates the main-scoped cache that this PR-only workflow restores from.
- name: Cache parity sidecar binary
id: sidecar-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
with:
path: .tmp-codex-parity-target
key: codex-parity-sidecar-${{ runner.os }}-${{ hashFiles('tests/codex_parity/sidecar/Cargo.lock') }}
path: .tmp-codex-parity-target/debug/codex-parity-sidecar
key: codex-parity-bin-${{ runner.os }}-${{ steps.rustc.outputs.version }}-${{ hashFiles('tests/codex_parity/sidecar/**') }}
- name: Build parity sidecar
if: steps.sidecar-cache.outputs.cache-hit != 'true'
run: |
cargo build \
--manifest-path tests/codex_parity/sidecar/Cargo.toml \
+13 -6
View File
@@ -19,6 +19,9 @@ on:
schedule:
- cron: "0 9 * * *"
pull_request:
# labeled/unlabeled: kept for the skip-security-scan recovery path
# (rerun-security-gate-run.yml falls back to this trigger). The concurrency
# group key isolates label events so they never cancel a code-push run.
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
paths-ignore: ['web/**', 'tests/e2e_ui/**']
workflow_dispatch:
@@ -34,8 +37,9 @@ on:
concurrency:
# PRs key by number, dispatch by branch (so re-runs cancel); schedule keys
# by SHA so each merge to `main` gets its own run.
group: e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.inputs.branch || github.sha }}
# by SHA so each merge to `main` gets its own run. Label events append the
# label name so they get an isolated slot and never cancel a code-push run.
group: e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.inputs.branch || github.sha }}-${{ (github.event.action == 'labeled' || github.event.action == 'unlabeled') && github.event.label.name || 'run' }}
cancel-in-progress: true
permissions:
@@ -54,11 +58,14 @@ env:
jobs:
# Security gate: untrusted PRs wait on the deterministic scan
# (security-gate.yml); trusted authors and non-PR events pass instantly.
# Skip when the automerge label is applied/removed -- safe to short-circuit
# here because every non-gate job is transitively downstream of gate, so
# no skipped check-run can overwrite an existing result on this SHA.
# Short-circuit for label events that aren't skip-security-scan (e.g.
# automerge): those run in their own isolated concurrency slot (above) and
# don't need the full suite — just exit fast.
gate:
if: github.event.label.name != 'automerge'
if: >-
github.event_name != 'pull_request' ||
(github.event.action != 'labeled' && github.event.action != 'unlabeled') ||
github.event.label.name == 'skip-security-scan'
uses: ./.github/workflows/security-gate.yml
# Shard matrix (e2e-shard-matrix.sh, shared with e2e-ui.yml). Fork PRs run by
+88
View File
@@ -0,0 +1,88 @@
name: Electron Build
# Manually-triggered build of the Electron desktop shell (web/electron) for
# Linux and Windows. Each platform packages on its own native runner —
# electron-builder does not reliably cross-compile installers — and uploads the
# installers as downloadable workflow artifacts. Unsigned: no signing creds are
# wired here, so `CSC_IDENTITY_AUTO_DISCOVERY=false` forces an unsigned build
# rather than failing when a cert is absent. No publishing / release upload.
#
# Run it from the Actions tab (Run workflow). macOS is intentionally omitted —
# its signed/notarized build lives elsewhere.
on:
workflow_dispatch:
inputs:
ref:
description: "Branch, tag, or SHA to build."
required: false
default: ""
permissions:
contents: read
concurrency:
# One build per ref: back-to-back manual dispatches on the same ref queue
# instead of running concurrently (keyed on ref only — including run_id would
# make every run its own group, defeating the serialization).
group: electron-build-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: Build (${{ matrix.platform }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
# Keep building the other platform even if one fails, so a Windows-only
# break still yields the Linux installers (and vice versa).
fail-fast: false
matrix:
include:
- os: ubuntu-latest
platform: linux
build-script: build:linux
- os: windows-latest
platform: win
build-script: build:win
steps:
- name: Check out repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.inputs.ref || github.ref }}
- name: Set up Node
uses: ./.github/actions/setup-node
with:
# Node 22.x per web/electron/README.md ("Prerequisites").
node-version: "22"
cache-dependency-path: web/electron/package-lock.json
- name: Install dependencies
working-directory: web/electron
run: npm ci --no-audit --no-fund
- name: Build ${{ matrix.platform }} app
working-directory: web/electron
env:
# No signing credentials in CI: force an unsigned build instead of
# letting electron-builder fail hunting for a certificate.
CSC_IDENTITY_AUTO_DISCOVERY: "false"
# electron-builder downloads Electron/tooling from GitHub; the token
# lifts the anonymous rate limit that otherwise flakes downloads.
GH_TOKEN: ${{ github.token }}
run: npm run ${{ matrix.build-script }} -- --publish never
- name: Upload installers
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: omnigent-desktop-${{ matrix.platform }}
# Ship only the distributables, not electron-builder's unpacked
# intermediates (dist/linux-unpacked, dist/win-unpacked, blockmaps).
path: |
web/electron/dist/*.AppImage
web/electron/dist/*.deb
web/electron/dist/*.exe
if-no-files-found: error
retention-days: 14
+8 -8
View File
@@ -503,10 +503,10 @@ jobs:
maintainer_assigned=true
fi
# Otherwise, assign an owner for P0/P1 issues: the LLM's top-ranked area
# owner, breaking ties by open-assigned-issue load (fairness). Symmetric
# with the PR reviewer path (rank primary, load secondary). Skipped if
# the maintainer-author was already assigned above.
# Otherwise, assign an owner for P0/P1 issues: the least-loaded area
# owner, with LLM rank as a tiebreaker (load primary, rank secondary).
# Symmetric with the PR reviewer path. Skipped if the maintainer-author
# was already assigned above.
priority=$(jq -r '.priority // empty' /tmp/triage_result.json)
if [ "$maintainer_assigned" = "false" ] && { [ "$priority" = "P0-critical" ] || [ "$priority" = "P1-high" ]; }; then
# Open-issue load per candidate (fewest assigned open issues wins ties).
@@ -533,12 +533,12 @@ jobs:
if a.get("login"):
load[a["login"]] += 1
# Sort by (rank, load, login): LLM rank first, then fewest open issues,
# then a stable alphabetical tie-break (deterministic, unlike a random
# one — matches the previous round-robin's determinism guarantee).
# Sort by (load, rank, login): fewest open assigned issues first so
# the workload stays balanced; LLM rank breaks ties within the same
# load bucket; alphabetical login is the final deterministic tiebreak.
candidates = sorted(
candidates,
key=lambda u: (rank_of.get(u, float("inf")), load[u], u),
key=lambda u: (load[u], rank_of.get(u, float("inf")), u),
)
assignee = candidates[0] if candidates else ""
if assignee:
+17
View File
@@ -102,6 +102,23 @@ jobs:
exit 1
}
# ktlint is invoked by the android-ktlint-* pre-commit hooks. The wrapper
# script (web/android/bin/ktlint.sh) exits 0 if ktlint is absent, so we
# install it here before pre-commit runs to ensure the check is enforced.
# The binary is verified against a pinned SHA-256 so a corrupted or spoofed
# download is caught before the binary is made executable.
- name: Install ktlint
env:
KTLINT_VERSION: "1.8.0"
KTLINT_SHA256: "a3fd620207d5c40da6ca789b95e7f823c54e854b7fade7f613e91096a3706d75"
run: |
curl -sSLf \
"https://github.com/ktlint/ktlint/releases/download/${KTLINT_VERSION}/ktlint" \
-o /tmp/ktlint
echo "${KTLINT_SHA256} /tmp/ktlint" | sha256sum -c
chmod +x /tmp/ktlint
sudo mv /tmp/ktlint /usr/local/bin/ktlint
- name: Run formatting, lint, and typing checks
run: uv run pre-commit run --all-files --show-diff-on-failure
+1 -1
View File
@@ -27,7 +27,7 @@ on:
pull_request_target:
types: [labeled]
workflow_run:
workflows: [PR Template, CI, Lint, E2E UI Tests, E2E Tests, Integration Tests]
workflows: [PR Template, CI, Lint, Docker build, E2E UI Tests, E2E Tests, Integration Tests]
types: [completed]
issue_comment:
types: [created]
+52 -67
View File
@@ -12,10 +12,8 @@
# `pip install omnigent` resolves to. Pre-releases never move it.
# :latest-rc the highest version OVERALL, max(release, rc) — the newest
# thing tagged, pre-release or not.
# :latest-dev the most recent main build (bleeding edge); moves on every
# qualifying main commit.
# :latest-nightly the most recent main build as of the daily cron; retagged
# from :latest-dev once a day (no rebuild).
# :latest-nightly the most recent nightly main build (bleeding edge); moves
# once a day when the scheduled build rebuilds main HEAD.
# Ordering for :latest / :latest-rc uses PEP 440 (1.2.3rc1 < 1.2.3), which
# `sort -V` gets wrong, so the max is computed with .github/scripts/
# oss-publish-images/maxver.py (Python `packaging`).
@@ -25,23 +23,15 @@
name: Publish images (public)
on:
# Release builds only — every v* tag push publishes the immutable version pin
# and moves the floating release tags. Per-commit main builds were retired in
# favour of the nightly rebuild below; PRs get a build-only check (docker-build.yml)
# so a broken image is caught before merge without a push.
push:
branches: [main]
tags: ['v*']
# Only rebuild when something that lands in the image changes.
paths:
- 'deploy/docker/Dockerfile'
- 'deploy/docker/entrypoint.py'
- 'omnigent/**'
- 'web/**'
- 'sdks/**'
- 'pyproject.toml'
- 'setup.py'
- 'uv.lock'
- 'web/package-lock.json'
- '.github/workflows/oss-publish-images.yml'
# Daily nightly promotion (07:00 UTC). Retags the current :latest-dev as
# :latest-nightly — handled by promote-nightly, not a rebuild.
# Nightly rebuild of main HEAD (07:00 UTC): the build-and-push job publishes
# :sha-<short> + :latest-nightly. This is what keeps bleeding-edge ~1 day
# fresh now that main commits no longer each trigger a build.
schedule:
- cron: '0 7 * * *'
workflow_dispatch:
@@ -50,10 +40,6 @@ on:
description: 'Also move :latest to this build (manual release of latest). Off by default.'
type: boolean
default: false
force_nightly:
description: 'Promote :latest-dev -> :latest-nightly now (runs only the nightly job). Off by default.'
type: boolean
default: false
reconcile_floating:
description: 'Repoint :latest and :latest-rc onto the correct existing version images (no rebuild). Runs only the reconcile job. Off by default.'
type: boolean
@@ -73,10 +59,11 @@ jobs:
permissions:
contents: read
packages: write # push the image to GHCR via GITHUB_TOKEN
# Gated to this repository; inert in forks and mirrors. Skip the (re)build
# on schedule, force_nightly, and reconcile_floating dispatches — those only
# drive the promote-nightly / reconcile-floating jobs.
if: github.repository == 'omnigent-ai/omnigent' && github.event_name != 'schedule' && !inputs.force_nightly && !inputs.reconcile_floating
# Gated to this repository; inert in forks and mirrors. Runs on tag pushes,
# the nightly schedule (rebuild of main HEAD), and bump_latest dispatches.
# Skipped on reconcile_floating dispatches — that only drives the
# reconcile-floating retag job.
if: github.repository == 'omnigent-ai/omnigent' && !inputs.reconcile_floating
runs-on: ubuntu-latest
# Multi-arch: the linux/arm64 leg cross-builds under QEMU emulation on this
# amd64 runner, which roughly doubles the host-image build time (emulated
@@ -124,23 +111,26 @@ jobs:
IMAGE="ghcr.io/omnigent-ai/omnigent-server"
HOST_IMAGE="ghcr.io/omnigent-ai/omnigent-host"
OPENSHELL_IMAGE="ghcr.io/omnigent-ai/omnigent-server-openshell"
KUBERNETES_IMAGE="ghcr.io/omnigent-ai/omnigent-server-kubernetes"
SHORT_SHA=$(git rev-parse --short HEAD)
# Immutable per-commit pin, always.
TAGS="${IMAGE}:sha-${SHORT_SHA}"
HOST_TAGS="${HOST_IMAGE}:sha-${SHORT_SHA}"
OPENSHELL_TAGS="${OPENSHELL_IMAGE}:sha-${SHORT_SHA}"
KUBERNETES_TAGS="${KUBERNETES_IMAGE}:sha-${SHORT_SHA}"
# Append a floating/version tag to all images.
add_tag() {
TAGS="${TAGS},${IMAGE}:$1"
HOST_TAGS="${HOST_TAGS},${HOST_IMAGE}:$1"
OPENSHELL_TAGS="${OPENSHELL_TAGS},${OPENSHELL_IMAGE}:$1"
KUBERNETES_TAGS="${KUBERNETES_TAGS},${KUBERNETES_IMAGE}:$1"
}
# Every qualifying main commit moves :latest-dev (bleeding edge).
# The nightly rebuild of main moves :latest-nightly (bleeding edge).
if [ "${GH_REF}" = "refs/heads/main" ]; then
add_tag "latest-dev"
add_tag "latest-nightly"
fi
if [[ "${GH_REF}" == refs/tags/v* ]]; then
@@ -175,6 +165,7 @@ jobs:
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
echo "host_tags=${HOST_TAGS}" >> "$GITHUB_OUTPUT"
echo "openshell_tags=${OPENSHELL_TAGS}" >> "$GITHUB_OUTPUT"
echo "kubernetes_tags=${KUBERNETES_TAGS}" >> "$GITHUB_OUTPUT"
# No build-args: the Dockerfile ARGs default to public registries.
# Multi-arch: each tag publishes as a manifest list spanning amd64 + arm64,
@@ -234,10 +225,32 @@ jobs:
cache-to: type=gha,mode=max
provenance: false
sbom: true
# Kubernetes server variant: the default server image plus the kubernetes
# client extra (OMNIGENT_EXTRAS=kubernetes), so `sandbox.provider:
# kubernetes` works without a self-built image. Used by the
# deploy/kubernetes/overlays/sandbox-runners kustomize overlay. Reuses
# the shared builder-stage layers from the gha cache.
- name: Build and push kubernetes server image
id: build-kubernetes
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: deploy/docker/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.tags.outputs.kubernetes_tags }}
build-args: |
OMNIGENT_EXTRAS=kubernetes
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
sbom: true
outputs:
server-digest: ${{ steps.build-server.outputs.digest }}
host-digest: ${{ steps.build-host.outputs.digest }}
openshell-digest: ${{ steps.build-openshell.outputs.digest }}
kubernetes-digest: ${{ steps.build-kubernetes.outputs.digest }}
generate-sbom:
# Runs in a separate job with read-only permissions so the Syft
@@ -281,6 +294,13 @@ jobs:
-o cyclonedx-json=openshell-sbom.cdx.json \
-o spdx-json=openshell-sbom.spdx.json
- name: Generate kubernetes server SBOM
run: |
set -euo pipefail
syft "ghcr.io/omnigent-ai/omnigent-server-kubernetes@${{ needs.build-and-push.outputs.kubernetes-digest }}" \
-o cyclonedx-json=kubernetes-sbom.cdx.json \
-o spdx-json=kubernetes-sbom.spdx.json
- name: Upload SBOMs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@@ -292,45 +312,10 @@ jobs:
host-sbom.spdx.json
openshell-sbom.cdx.json
openshell-sbom.spdx.json
kubernetes-sbom.cdx.json
kubernetes-sbom.spdx.json
retention-days: 90
promote-nightly:
# Daily cron (or a manual force_nightly dispatch): move :latest-nightly to
# the current main build by retagging :latest-dev with `crane tag`
# (digest-preserving, no rebuild).
if: github.repository == 'omnigent-ai/omnigent' && (github.event_name == 'schedule' || inputs.force_nightly)
permissions:
contents: read
packages: write # retag within GHCR via GITHUB_TOKEN
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Set up crane
uses: imjasonh/setup-crane@59c71e96a00b28651f10369ba3359a6d730740a0 # v0.6
with:
version: v0.21.6
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Promote latest-dev -> latest-nightly
run: |
set -euo pipefail
# crane tag points a new tag at an EXISTING manifest digest without
# re-serializing it, so :latest-nightly keeps :latest-dev's exact digest.
for img in ghcr.io/omnigent-ai/omnigent-server ghcr.io/omnigent-ai/omnigent-host ghcr.io/omnigent-ai/omnigent-server-openshell; do
if crane digest "${img}:latest-dev" >/dev/null 2>&1; then
crane tag "${img}:latest-dev" latest-nightly
echo "promoted ${img}:latest-dev -> :latest-nightly ($(crane digest "${img}:latest-nightly"))"
else
echo "::warning::${img}:latest-dev not found yet; skipping nightly promotion"
fi
done
reconcile-floating:
# Manual reconcile (workflow_dispatch with reconcile_floating=true): repoint
# :latest and :latest-rc onto the correct EXISTING version images, computed
@@ -393,7 +378,7 @@ jobs:
fi
}
for img in ghcr.io/omnigent-ai/omnigent-server ghcr.io/omnigent-ai/omnigent-host ghcr.io/omnigent-ai/omnigent-server-openshell; do
for img in ghcr.io/omnigent-ai/omnigent-server ghcr.io/omnigent-ai/omnigent-host ghcr.io/omnigent-ai/omnigent-server-openshell ghcr.io/omnigent-ai/omnigent-server-kubernetes; do
retag "${img}" "latest-rc" "${RC_TAG}"
retag "${img}" "latest" "${LATEST_TAG}"
done
+35
View File
@@ -0,0 +1,35 @@
name: Reviewer SLA Test
# Offline unit test for the SLA sweep logic: runs review-sla.test.js (mocked
# GitHub client, real .github/MAINTAINER; ownership pinned to a frozen fixture).
# Triggers only when the sweep, its test, or the pool files it reads change. Runs
# on `pull_request` (PR head checkout) so it tests the PR's own version. No
# secrets, no network.
on:
pull_request:
paths:
- .github/workflows/review-sla.js
- .github/workflows/review-sla.test.js
- .github/workflows/review-sla.yml
- .github/MAINTAINER
- .github/areas.json
workflow_dispatch:
permissions:
contents: read
concurrency:
group: review-sla-test-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run reviewer-SLA unit test
run: node .github/workflows/review-sla.test.js
+340
View File
@@ -0,0 +1,340 @@
// Reviewer SLA sweep: nudge + escalate open PRs and issues that a MAINTAINER has
// been sitting on for more than SLA_DAYS *working* days without replying.
//
// Runs on a schedule from the trusted default branch (see review-sla.yml), so it
// reads no PR-authored code and just talks to the issues/PRs API. For each open,
// non-draft item:
// - PRs: the "assigned person" is any maintainer in requested_reviewers (GitHub
// drops them from that list the moment they submit a review, so being in it
// means "still owes a review"). The clock starts at their latest
// `review_requested` event (fallback: PR opened). If >= SLA_DAYS working days
// have elapsed AND they've posted no comment or review since, the SLA is
// breached: re-ping them in one comment and add ONE second reviewer (lowest
// open-review load among the area owners in .github/areas.json, mirrored as
// an assignee like auto-assign-reviewer.js does).
// - Issues: the "assigned person" is any maintainer assignee; clock starts at
// their latest `assigned` event. Breach -> re-ping + add one second assignee
// from the owners of the area(s) whose comp:* label the issue carries.
//
// Ownership comes from .github/areas.json -- the single source of truth shared
// with auto-assign-reviewer.js and issue-triage.yml (it replaced the old
// .github/reviewers + .github/ISSUE_ASSIGNEES files). `owners_paused` is ignored.
//
// "Working days" = weekdays (Mon-Fri) in UTC. Reply = ANY comment or review by the
// assignee since the clock started.
//
// Escalate-once, two independent guards so the bot never spams:
// 1. a one-shot LABEL, and
// 2. the MARKER hidden in the reminder comment -- checked as a fallback so that
// even if the label write fails after the comment lands, the next sweep still
// sees the marker and skips.
// The second reviewer/assignee is added FIRST (best-effort); the comment is then
// worded to match what actually happened (so it can't claim "Adding @X" when the
// add 422'd), and the label is written last. If the comment itself fails nothing
// user-visible was posted, so we skip the label and let the next sweep retry.
//
// ponytail: one escalation per item. Per-reviewer re-escalation or a weekly
// re-ping would need per-nudge timestamp state instead of the label+marker pair --
// add that only if a single nudge proves too weak.
const fs = require("fs");
const SLA_DAYS = 5; // working days
const LABEL = "review-sla-escalated";
const MARKER = "<!-- review-sla-bot -->"; // idempotency fallback if the label write fails
const CANONICAL_REPO = "omnigent-ai/omnigent";
// Max escalations per sweep. Bounds the day-one blast against an existing stale
// backlog (and any future surge): the backlog drains a chunk per weekday instead
// of nudging everything at once. PRs are processed before issues.
// ponytail: single global cap; split into per-kind caps if issue nudges starving
// behind a large PR backlog ever matters.
const MAX_ESCALATIONS_PER_RUN = 30;
// --- Pure helpers (exported for the offline test; no network) --------------
// Weekdays strictly after `from`'s date, through `to`'s date, in UTC. So a review
// requested on a Monday first counts as 5 working days the following Monday.
// ponytail: weekends only, no holiday calendar -- add one if the SLA needs it.
function workingDaysBetween(from, to) {
const cur = new Date(from);
cur.setUTCHours(0, 0, 0, 0);
const end = new Date(to);
end.setUTCHours(0, 0, 0, 0);
let count = 0;
while (cur < end) {
cur.setUTCDate(cur.getUTCDate() + 1);
const d = cur.getUTCDay();
if (d !== 0 && d !== 6) count++;
}
return count;
}
// Latest ISO timestamp per (lowercased) login for a given timeline event type.
function latestByUser(timeline, eventName, getLogin) {
const out = {};
for (const e of timeline || []) {
if (e.event !== eventName) continue;
const login = getLogin(e);
if (!login || !e.created_at) continue;
const lc = login.toLowerCase();
if (!out[lc] || new Date(e.created_at) > new Date(out[lc])) out[lc] = e.created_at;
}
return out;
}
// Did `login` post any comment/review after `sinceIso`?
function repliedSince(login, sinceIso, comments, reviews, reviewComments) {
const since = new Date(sinceIso).getTime();
const lc = login.toLowerCase();
const by = (u) => (u || "").toLowerCase() === lc;
const after = (t) => t && new Date(t).getTime() > since;
return (
(comments || []).some((c) => by(c.user && c.user.login) && after(c.created_at)) ||
(reviews || []).some((r) => by(r.user && r.user.login) && after(r.submitted_at)) ||
(reviewComments || []).some((rc) => by(rc.user && rc.user.login) && after(rc.created_at))
);
}
// Have we already posted a reminder here? (idempotency fallback for a failed label)
function alreadyNudged(comments) {
return (comments || []).some((c) => (c.body || "").includes(MARKER));
}
// Breached maintainer targets for one item, given the reply signals. Shared by the
// PR and issue paths (issues pass [] for reviews/reviewComments).
function breachedTargets({ targets, clockStartByUser, openedAt, now, comments, reviews, reviewComments }) {
const out = [];
for (const t of targets) {
// Fallback to openedAt when there's no explicit request/assign event for
// this login (e.g. a CODEOWNERS/team expansion, or a timeline pagination
// edge). That can over-count elapsed time slightly -- acceptable, and never
// fires for the normal auto-assigned path which always emits the event.
const since = clockStartByUser[t.toLowerCase()] || openedAt;
if (workingDaysBetween(since, now) < SLA_DAYS) continue;
if (repliedSince(t, since, comments, reviews, reviewComments)) continue;
out.push(t);
}
return out;
}
// Parse .github/areas.json (same shape auto-assign-reviewer.js reads) into:
// rules - [{ prefix, owners }] in document order (last match wins per file)
// pool - Map lc->original of every owner (the full candidate set)
// labelOwners - Map "comp:x" -> Set of owners, for routing an issue by its label
// `owners_paused` is intentionally ignored. `text` is injectable for tests.
function parseAreas(text) {
const areas = JSON.parse(text).areas || [];
const rules = [];
const pool = new Map();
const labelOwners = new Map();
for (const area of areas) {
const owners = area.owners || [];
owners.forEach((o) => pool.set(o.toLowerCase(), o));
for (const p of area.paths || []) rules.push({ prefix: p.replace(/^\//, ""), owners });
if (area.label) {
const set = labelOwners.get(area.label) || new Set();
owners.forEach((o) => set.add(o));
labelOwners.set(area.label, set);
}
}
return { rules, pool, labelOwners };
}
// Count currently-open review requests per (lc) login -- the stateless fairness
// signal auto-assign-reviewer.js also uses.
function buildLoad(openPRs) {
const load = new Map();
for (const p of openPRs)
for (const r of p.requested_reviewers || []) {
const l = (r.login || "").toLowerCase();
load.set(l, (load.get(l) || 0) + 1);
}
return load;
}
// Pick the lowest-load of a candidate list, random tie-break within a load tier.
function lowestLoad(candidates, load) {
if (!candidates.length) return null;
const loadOf = (u) => load.get(u.toLowerCase()) || 0;
const byTier = {};
for (const u of candidates) (byTier[loadOf(u)] ||= []).push(u);
const lowest = byTier[Math.min(...Object.keys(byTier).map(Number))];
return lowest[Math.floor(Math.random() * lowest.length)];
}
// One lowest-load area owner for the PR's files, else lowest from the full pool;
// never anyone already on the PR.
function pickSecondReviewer({ files, rules, pool, load, exclude }) {
const areaOwners = new Map();
for (const f of files) {
let match = null;
for (const r of rules) if (f.startsWith(r.prefix)) match = r; // last wins
if (match) match.owners.forEach((o) => areaOwners.set(o.toLowerCase(), o));
}
const base = areaOwners.size ? areaOwners : pool;
return lowestLoad([...base.values()].filter((u) => !exclude.has(u.toLowerCase())), load);
}
// One second assignee from the owners of the issue's comp:* area(s), else the full
// pool; never anyone already assigned.
// ponytail: tie-break reuses the PR open-review `load` -- a proxy for issues (there
// is no per-assignee open-issue count), so this only approximates issue fairness.
// Tally open-issue assignee counts here if that starts to matter.
function pickSecondAssignee({ labels, labelOwners, pool, load, exclude }) {
const owners = new Set();
for (const l of labels) for (const o of labelOwners.get(l) || []) owners.add(o);
const base = owners.size ? owners : new Set(pool.values());
return lowestLoad([...base].filter((u) => !exclude.has(u.toLowerCase())), load);
}
// --- Orchestrator ----------------------------------------------------------
async function run({ github, context, core }) {
const { owner, repo } = context.repo;
if (`${owner}/${repo}` !== CANONICAL_REPO) {
core.info(`Not ${CANONICAL_REPO}; skipping.`);
return;
}
const now = new Date();
const maintainers = new Set(
fs.readFileSync(".github/MAINTAINER", "utf8")
.split("\n").map((l) => l.replace(/#.*/, "").trim().toLowerCase()).filter(Boolean)
);
// REVIEWER_AREAS_FILE lets the unit test pin a fixture; defaults to the real file.
const areasFile = process.env.REVIEWER_AREAS_FILE || ".github/areas.json";
const { rules, pool, labelOwners } = parseAreas(fs.readFileSync(areasFile, "utf8"));
const hasLabel = (item) => (item.labels || []).some((l) => (l.name || l) === LABEL);
const escalated = [];
const capReached = () => escalated.length >= MAX_ESCALATIONS_PER_RUN;
// Escalate one item once. Add the second reviewer/assignee FIRST (best-effort,
// returns the login it actually added or null), so the comment states the true
// outcome; then post the marked comment; then lock the LABEL. If the comment
// fails, nothing was posted -> skip the label and retry next sweep.
const escalateOnce = async (number, breached, kind, addSecond, secondCandidate) => {
let added = null;
if (secondCandidate) {
try {
added = (await addSecond()) ? secondCandidate : null;
} catch (e) {
core.warning(`#${number}: could not add second ${kind} @${secondCandidate}: ${e.message}`);
}
}
const noun = kind === "reviewer" ? "review" : "a response";
const body =
`${MARKER}\n⏰ **${kind === "reviewer" ? "Reviewer" : "Response"} SLA** — this ${kind === "reviewer" ? "PR" : "issue"} ` +
`has been awaiting ${noun} from ${breached.map((u) => "@" + u).join(", ")} for more than ${SLA_DAYS} working days.` +
(added ? ` Adding @${added} as a second ${kind}.` : "");
try {
await github.rest.issues.createComment({ owner, repo, issue_number: number, body });
} catch (e) {
core.warning(`#${number}: reminder comment failed, will retry next run: ${e.message}`);
return;
}
try {
await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [LABEL] });
} catch (e) {
core.warning(`#${number}: could not add ${LABEL} label (marker still guards re-nudge): ${e.message}`);
}
escalated.push(`${kind === "reviewer" ? "PR" : "issue"} #${number} (re-pinged ${breached.join(", ")}${added ? `, +@${added}` : ""})`);
};
// ----- PRs: awaiting a maintainer's review -----
const openPRs = await github.paginate(github.rest.pulls.list, { owner, repo, state: "open", per_page: 100 });
const load = buildLoad(openPRs);
// Count each second reviewer/assignee we add during THIS sweep against the load
// map, so successive picks rotate instead of dogpiling the current lowest-load
// maintainer -- without it, one sweep hands nearly every escalation to one person.
const bumpLoad = (u) => load.set(u.toLowerCase(), (load.get(u.toLowerCase()) || 0) + 1);
for (const pr of openPRs) {
if (capReached()) break;
if (pr.draft || hasLabel(pr)) continue;
const targets = (pr.requested_reviewers || []).map((r) => r.login).filter((l) => maintainers.has(l.toLowerCase()));
if (!targets.length) continue;
const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { owner, repo, issue_number: pr.number, per_page: 100 });
const requestedAt = latestByUser(timeline, "review_requested", (e) => e.requested_reviewer && e.requested_reviewer.login);
// Cheap staleness prefilter before fetching reply signals.
const stale = targets.filter((t) => workingDaysBetween(requestedAt[t.toLowerCase()] || pr.created_at, now) >= SLA_DAYS);
if (!stale.length) continue;
const [comments, reviews, reviewComments] = await Promise.all([
github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }),
github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: pr.number, per_page: 100 }),
github.paginate(github.rest.pulls.listReviewComments, { owner, repo, pull_number: pr.number, per_page: 100 }),
]);
if (alreadyNudged(comments)) continue; // label may have failed to write; marker still guards
const breached = breachedTargets({
targets: stale, clockStartByUser: requestedAt, openedAt: pr.created_at, now, comments, reviews, reviewComments,
});
if (!breached.length) continue;
const files = (await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 })).map((f) => f.filename);
const onPr = new Set(
[pr.user && pr.user.login, ...targets, ...(pr.assignees || []).map((a) => a.login), ...(pr.requested_reviewers || []).map((r) => r.login)]
.filter(Boolean).map((s) => s.toLowerCase())
);
const second = pickSecondReviewer({ files, rules, pool, load, exclude: onPr });
await escalateOnce(pr.number, breached, "reviewer", async () => {
await github.rest.pulls.requestReviewers({ owner, repo, pull_number: pr.number, reviewers: [second] });
// Mirror as assignee for UI filterability, matching auto-assign-reviewer.js.
await github.rest.issues.addAssignees({ owner, repo, issue_number: pr.number, assignees: [second] });
bumpLoad(second);
return true;
}, second);
}
// ----- Issues: awaiting a maintainer assignee -----
const openIssues = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: "open", per_page: 100 });
for (const issue of openIssues) {
if (capReached()) break;
if (issue.pull_request || hasLabel(issue)) continue; // listForRepo also returns PRs
const targets = (issue.assignees || []).map((a) => a.login).filter((l) => maintainers.has(l.toLowerCase()));
if (!targets.length) continue;
const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { owner, repo, issue_number: issue.number, per_page: 100 });
const assignedAt = latestByUser(timeline, "assigned", (e) => e.assignee && e.assignee.login);
const stale = targets.filter((t) => workingDaysBetween(assignedAt[t.toLowerCase()] || issue.created_at, now) >= SLA_DAYS);
if (!stale.length) continue;
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: issue.number, per_page: 100 });
if (alreadyNudged(comments)) continue;
const breached = breachedTargets({
targets: stale, clockStartByUser: assignedAt, openedAt: issue.created_at, now, comments, reviews: [], reviewComments: [],
});
if (!breached.length) continue;
const labels = (issue.labels || []).map((l) => l.name || l).filter((n) => n.startsWith("comp:"));
const onIssue = new Set((issue.assignees || []).map((a) => a.login.toLowerCase()));
const second = pickSecondAssignee({ labels, labelOwners, pool, load, exclude: onIssue });
await escalateOnce(issue.number, breached, "assignee", async () => {
await github.rest.issues.addAssignees({ owner, repo, issue_number: issue.number, assignees: [second] });
bumpLoad(second);
return true;
}, second);
}
core.info(escalated.length ? `Escalated ${escalated.length}: ${escalated.join("; ")}.` : "No SLA breaches; nothing to escalate.");
}
module.exports = run;
// Exported for the offline unit test.
module.exports.workingDaysBetween = workingDaysBetween;
module.exports.latestByUser = latestByUser;
module.exports.repliedSince = repliedSince;
module.exports.alreadyNudged = alreadyNudged;
module.exports.breachedTargets = breachedTargets;
module.exports.parseAreas = parseAreas;
module.exports.pickSecondReviewer = pickSecondReviewer;
module.exports.pickSecondAssignee = pickSecondAssignee;
module.exports.SLA_DAYS = SLA_DAYS;
module.exports.LABEL = LABEL;
module.exports.MARKER = MARKER;
module.exports.MAX_ESCALATIONS_PER_RUN = MAX_ESCALATIONS_PER_RUN;
+237
View File
@@ -0,0 +1,237 @@
// Offline unit test for review-sla.js -- exercises the pure decision helpers and
// one end-to-end orchestration of each path against a mocked GitHub client. No
// network. cwd must be the repo root (the orchestrator reads the real
// .github/MAINTAINER; ownership is pinned to a frozen fixture via
// REVIEWER_AREAS_FILE so the test doesn't churn when .github/areas.json changes).
const path = require("path");
const os = require("os");
const fs = require("fs");
const script = require(path.resolve(".github/workflows/review-sla.js"));
// Frozen area fixture: stable owners the orchestration assertions can pin to.
const FIXTURE = {
areas: [
{ key: "inner", label: "comp:harnesses", paths: ["omnigent/inner/"], owners: ["ownerA", "ownerB", "ownerC"] },
{ key: "web", label: "comp:web-ui", paths: ["web/"], owners: ["webX", "webY"] },
],
};
const FIXTURE_PATH = path.join(os.tmpdir(), "review-sla-areas.fixture.json");
fs.writeFileSync(FIXTURE_PATH, JSON.stringify(FIXTURE));
process.env.REVIEWER_AREAS_FILE = FIXTURE_PATH;
function assert(name, cond, detail) {
console.log(`${cond ? "PASS" : "FAIL"} ${name}${detail ? " -- " + detail : ""}`);
if (!cond) process.exitCode = 1;
}
const daysAgoIso = (n) => new Date(Date.now() - n * 86400000).toISOString();
// Mocked GitHub client. `canned` maps a list-endpoint tag -> the array it returns
// through github.paginate; writes are recorded in `sink`. `failRequestReviewers`
// makes pulls.requestReviewers throw, to exercise the partial-failure path.
function mkGithub(canned, sink, opts = {}) {
const list = (tag) => { const f = async () => {}; f._tag = tag; return f; };
return {
paginate: async (fn) => canned[fn._tag] || [],
rest: {
pulls: {
list: list("openPRs"),
listReviews: list("reviews"),
listReviewComments: list("reviewComments"),
listFiles: list("files"),
requestReviewers: async (a) => {
if (opts.failRequestReviewers) throw new Error("HTTP 422: reviewer is not a collaborator");
sink.requested.push(...a.reviewers);
},
},
issues: {
listForRepo: list("openIssues"),
listEventsForTimeline: list("timeline"),
listComments: list("comments"),
createComment: async (a) => sink.comments.push(a),
addAssignees: async (a) => sink.assigned.push(...a.assignees),
addLabels: async (a) => sink.labels.push(...a.labels),
},
},
};
}
async function runOrch(canned, opts) {
const sink = { comments: [], requested: [], assigned: [], labels: [], warnings: [] };
const core = { info: () => {}, warning: (m) => sink.warnings.push(m) };
const context = { repo: { owner: "omnigent-ai", repo: "omnigent" } };
await script({ github: mkGithub(canned, sink, opts), context, core });
return sink;
}
(async () => {
// ---- workingDaysBetween (2026-01-05 is a Monday, 01-12 the next Monday) ----
const wdb = script.workingDaysBetween;
assert("same day -> 0", wdb("2026-01-05", "2026-01-05") === 0);
assert("Mon -> next Mon (7 cal days) -> 5 working days", wdb("2026-01-05", "2026-01-12") === 5, String(wdb("2026-01-05", "2026-01-12")));
assert("Fri -> Mon spans a weekend -> 1", wdb("2026-01-09", "2026-01-12") === 1, String(wdb("2026-01-09", "2026-01-12")));
assert("Sat -> Sun -> 0", wdb("2026-01-10", "2026-01-11") === 0);
// ---- latestByUser ----
const tl = [
{ event: "review_requested", requested_reviewer: { login: "Alice" }, created_at: "2026-01-01T00:00:00Z" },
{ event: "review_requested", requested_reviewer: { login: "Alice" }, created_at: "2026-01-03T00:00:00Z" },
{ event: "assigned", assignee: { login: "Bob" }, created_at: "2026-01-02T00:00:00Z" },
];
const rq = script.latestByUser(tl, "review_requested", (e) => e.requested_reviewer && e.requested_reviewer.login);
assert("latestByUser keeps the newer event", rq.alice === "2026-01-03T00:00:00Z", JSON.stringify(rq));
assert("latestByUser ignores other event types", !("bob" in rq));
// ---- repliedSince ----
const since = "2026-01-01T00:00:00Z";
assert("comment after -> replied",
script.repliedSince("alice", since, [{ user: { login: "Alice" }, created_at: "2026-01-02T00:00:00Z" }], [], []) === true);
assert("comment before -> not replied",
script.repliedSince("alice", since, [{ user: { login: "Alice" }, created_at: "2025-12-31T00:00:00Z" }], [], []) === false);
assert("review after -> replied",
script.repliedSince("alice", since, [], [{ user: { login: "alice" }, submitted_at: "2026-01-05T00:00:00Z" }], []) === true);
assert("someone else's comment -> not replied",
script.repliedSince("alice", since, [{ user: { login: "Bob" }, created_at: "2026-01-09T00:00:00Z" }], [], []) === false);
// ---- alreadyNudged (marker fallback) ----
assert("alreadyNudged: marker present -> true", script.alreadyNudged([{ body: "hi " + script.MARKER }]) === true);
assert("alreadyNudged: no marker -> false", script.alreadyNudged([{ body: "just a normal comment" }]) === false);
// ---- breachedTargets ----
const now = new Date();
const b1 = script.breachedTargets({
targets: ["Alice"], clockStartByUser: { alice: daysAgoIso(14) }, openedAt: daysAgoIso(30), now,
comments: [], reviews: [], reviewComments: [],
});
assert("stale + silent -> breached", JSON.stringify(b1) === JSON.stringify(["Alice"]), JSON.stringify(b1));
const b2 = script.breachedTargets({
targets: ["Alice"], clockStartByUser: { alice: daysAgoIso(1) }, openedAt: daysAgoIso(1), now,
comments: [], reviews: [], reviewComments: [],
});
assert("within SLA -> not breached", b2.length === 0, JSON.stringify(b2));
const b3 = script.breachedTargets({
targets: ["Alice"], clockStartByUser: { alice: daysAgoIso(14) }, openedAt: daysAgoIso(30), now,
comments: [{ user: { login: "Alice" }, created_at: daysAgoIso(1) }], reviews: [], reviewComments: [],
});
assert("stale but replied -> not breached", b3.length === 0, JSON.stringify(b3));
// ---- parseAreas ----
const { rules, pool, labelOwners } = script.parseAreas(JSON.stringify(FIXTURE));
assert("parseAreas: rules preserve prefixes", rules.some((r) => r.prefix === "omnigent/inner/") && rules.some((r) => r.prefix === "web/"), JSON.stringify(rules));
assert("parseAreas: pool unions all owners", ["ownera", "ownerb", "ownerc", "webx", "weby"].every((o) => pool.has(o)), JSON.stringify([...pool.keys()]));
assert("parseAreas: labelOwners maps comp:* -> owners", [...(labelOwners.get("comp:web-ui") || [])].sort().join(",") === "webX,webY", JSON.stringify([...(labelOwners.get("comp:web-ui") || [])]));
// ---- pickSecondReviewer ----
const srMembers = script.pickSecondReviewer({
files: ["omnigent/inner/foo.py"], rules, pool, load: new Map(),
exclude: new Set(["ownera"]),
});
assert("second reviewer is an inner owner, excluding those on the PR",
["ownerb", "ownerc"].includes((srMembers || "").toLowerCase()), String(srMembers));
const srLoad = script.pickSecondReviewer({
files: ["omnigent/inner/foo.py"], rules, pool,
load: new Map([["ownera", 5], ["ownerb", 5], ["ownerc", 0]]),
exclude: new Set(),
});
assert("lowest-load owner wins the tie-break", (srLoad || "").toLowerCase() === "ownerc", String(srLoad));
const srFallback = script.pickSecondReviewer({
files: ["README.md"], rules, pool, load: new Map(), exclude: new Set(),
});
assert("unowned path -> falls back to the full pool", pool.has((srFallback || "").toLowerCase()), String(srFallback));
// ---- pickSecondAssignee ----
const saMatch = script.pickSecondAssignee({
labels: ["comp:web-ui"], labelOwners, pool, load: new Map(), exclude: new Set(["webx"]),
});
assert("second assignee comes from the label's owners, excluding the current one",
(saMatch || "").toLowerCase() === "weby", String(saMatch));
const saFallback = script.pickSecondAssignee({
labels: [], labelOwners, pool, load: new Map(), exclude: new Set(),
});
assert("no comp label -> falls back to the full pool", pool.has((saFallback || "").toLowerCase()), String(saFallback));
// ---- orchestration: a stale, silent PR gets nudged + a 2nd reviewer + label --
const stalePR = {
number: 7, draft: false, labels: [], user: { login: "someexternaldev" },
created_at: daysAgoIso(14), requested_reviewers: [{ login: "dhruv0811" }], assignees: [{ login: "dhruv0811" }],
};
let s = await runOrch({
openPRs: [stalePR], openIssues: [], timeline: [], comments: [], reviews: [], reviewComments: [],
files: [{ filename: "omnigent/inner/foo.py" }],
});
assert("stale PR: one reminder comment posted", s.comments.length === 1 && s.comments[0].issue_number === 7, JSON.stringify(s.comments));
assert("stale PR: comment re-pings the assigned reviewer", /@dhruv0811/.test(s.comments[0].body), s.comments[0] && s.comments[0].body);
assert("stale PR: a second reviewer is requested from the area owners",
s.requested.length === 1 && ["ownera", "ownerb", "ownerc"].includes(s.requested[0].toLowerCase()), JSON.stringify(s.requested));
assert("stale PR: second reviewer mirrored as assignee", JSON.stringify(s.assigned) === JSON.stringify(s.requested), JSON.stringify(s.assigned));
assert("stale PR: comment names exactly the reviewer that was added",
new RegExp(`Adding @${s.requested[0]} as a second reviewer`).test(s.comments[0].body), s.comments[0] && s.comments[0].body);
assert("stale PR: comment carries the idempotency marker", s.comments[0].body.includes(script.MARKER), s.comments[0] && s.comments[0].body);
assert("stale PR: labelled once", JSON.stringify(s.labels) === JSON.stringify([script.LABEL]), JSON.stringify(s.labels));
// ---- orchestration: partial failure -- requestReviewers throws --
// add-first ordering means the comment must NOT claim a 2nd reviewer that failed
// to attach, yet the item is still labelled so it won't be re-nudged tomorrow.
s = await runOrch({
openPRs: [stalePR], openIssues: [], timeline: [], comments: [], reviews: [], reviewComments: [],
files: [{ filename: "omnigent/inner/foo.py" }],
}, { failRequestReviewers: true });
assert("partial failure: reminder comment still posted", s.comments.length === 1, JSON.stringify(s.comments));
assert("partial failure: comment does NOT over-claim a second reviewer", !/second reviewer/.test(s.comments[0].body), s.comments[0] && s.comments[0].body);
assert("partial failure: no reviewer was actually requested", s.requested.length === 0, JSON.stringify(s.requested));
assert("partial failure: still labelled (won't re-nudge next run)", JSON.stringify(s.labels) === JSON.stringify([script.LABEL]), JSON.stringify(s.labels));
assert("partial failure: the reviewer-add error is warned, not fatal", s.warnings.some((w) => /could not add second reviewer/.test(w)), JSON.stringify(s.warnings));
// ---- orchestration: marker fallback -- prior nudge exists but the label didn't --
s = await runOrch({
openPRs: [stalePR], openIssues: [], timeline: [], reviews: [], reviewComments: [],
files: [{ filename: "omnigent/inner/foo.py" }],
comments: [{ user: { login: "omnigent-ci" }, body: script.MARKER + "\nearlier nudge", created_at: daysAgoIso(2) }],
});
assert("marker fallback: an already-nudged PR (marker present, no label) is skipped",
s.comments.length === 0 && s.labels.length === 0, JSON.stringify(s));
// ---- orchestration: already-labelled PR is left alone (one-shot) ----
s = await runOrch({ openPRs: [{ ...stalePR, labels: [{ name: script.LABEL }] }], openIssues: [], files: [] });
assert("already-escalated PR is skipped", s.comments.length === 0 && s.labels.length === 0, JSON.stringify(s));
// ---- orchestration: a fresh PR (within SLA) is left alone ----
s = await runOrch({ openPRs: [{ ...stalePR, created_at: daysAgoIso(1) }], openIssues: [], timeline: [], files: [] });
assert("fresh PR is not escalated", s.comments.length === 0, JSON.stringify(s));
// ---- orchestration: a PR whose reviewer already commented is left alone ----
s = await runOrch({
openPRs: [stalePR], openIssues: [], timeline: [], reviews: [], reviewComments: [], files: [],
comments: [{ user: { login: "dhruv0811" }, created_at: daysAgoIso(1) }],
});
assert("PR with a recent reply is not escalated", s.comments.length === 0, JSON.stringify(s));
// ---- orchestration: a stale, silent issue gets nudged + a 2nd assignee + label --
const staleIssue = {
number: 9, labels: [{ name: "comp:web-ui" }], created_at: daysAgoIso(14), assignees: [{ login: "hzub" }],
};
s = await runOrch({ openPRs: [], openIssues: [staleIssue], timeline: [], comments: [] });
assert("stale issue: one reminder comment posted", s.comments.length === 1 && s.comments[0].issue_number === 9, JSON.stringify(s.comments));
assert("stale issue: re-pings the assignee", /@hzub/.test(s.comments[0].body), s.comments[0] && s.comments[0].body);
assert("stale issue: a second assignee from the label's owners", ["webx", "weby"].includes((s.assigned[0] || "").toLowerCase()), JSON.stringify(s.assigned));
assert("stale issue: labelled once", JSON.stringify(s.labels) === JSON.stringify([script.LABEL]), JSON.stringify(s.labels));
// ---- orchestration: a real PR object (listForRepo) is not double-swept as an issue --
s = await runOrch({ openPRs: [], openIssues: [{ ...staleIssue, pull_request: {} }], timeline: [], comments: [] });
assert("PR returned by listForRepo is skipped in the issue sweep", s.comments.length === 0, JSON.stringify(s));
// ---- orchestration: per-run cap + in-sweep load spread ----
// Feed more stale PRs than the cap. Expect exactly MAX escalations, and the
// second reviewer rotates across all 3 inner owners rather than dogpiling the
// one lowest-load maintainer (regression for the live-data concentration bug).
const MAX = script.MAX_ESCALATIONS_PER_RUN;
const manyStale = Array.from({ length: MAX + 5 }, (_, i) => ({ ...stalePR, number: 3000 + i }));
s = await runOrch({
openPRs: manyStale, openIssues: [], timeline: [], comments: [], reviews: [], reviewComments: [],
files: [{ filename: "omnigent/inner/foo.py" }],
});
assert("cap: escalations stop at MAX_ESCALATIONS_PER_RUN", s.comments.length === MAX, `${s.comments.length} vs ${MAX}`);
assert("cap: labels capped to match", s.labels.length === MAX, String(s.labels.length));
assert("load spread: second reviewer rotates across all 3 inner owners (not dogpiled on one)",
new Set(s.requested.map((u) => u.toLowerCase())).size === 3, JSON.stringify([...new Set(s.requested)]));
})();
+49
View File
@@ -0,0 +1,49 @@
name: Reviewer SLA
# Daily (weekday) sweep that enforces a 5-working-day reviewer SLA: any open PR
# awaiting review from a maintainer -- or open issue awaiting a maintainer
# assignee -- with no reply in 5 working days gets the assignee re-pinged in a
# comment plus a second reviewer (PR) / second assignee (issue), then a one-shot
# `review-sla-escalated` label so it's never nudged twice. All logic + safety
# notes live in review-sla.js (offline unit test: review-sla.test.js).
#
# Scheduled -> runs on the trusted default branch with the repo GITHUB_TOKEN; it
# reads no PR-authored code, only .github/ config + the issues/PRs API.
on:
schedule:
- cron: "0 8 * * 1-5" # 08:00 UTC, Mon-Fri (weekday SLA -> no weekend pings)
workflow_dispatch:
permissions:
contents: read
concurrency:
group: review-sla
cancel-in-progress: true
jobs:
sweep:
if: github.repository == 'omnigent-ai/omnigent'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
# Job-level permissions REPLACE the workflow-level block, so restate read.
contents: read
pull-requests: write # comment + request the second reviewer
issues: write # comment + assign + label
steps:
# Trusted default branch, .github only (config the script reads). Never PR head.
- name: Check out .github
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.repository.default_branch }}
sparse-checkout: .github
persist-credentials: false
- name: Sweep open PRs + issues for SLA breaches
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
retries: 3
script: |
const script = require('./.github/workflows/review-sla.js');
await script({ github, context, core });
+4
View File
@@ -51,6 +51,10 @@ run-omnigents.sh
artifacts/
.tmp-codex-parity-target/
# omnidev (dev pod supervisor) Rust build output. Pod state lives outside the
# repo under ~/.cache/omnidev/, so only the build dir needs ignoring.
dev/omnidev/target/
# Playwright test run output (screenshots, traces, videos).
test-results/
+20
View File
@@ -46,6 +46,26 @@ repos:
# fights the tooling).
exclude: ^(omnigent/server/static/web-ui/assets/|web/.*\.xcassets/|web/.*\.icon/)
# Android Kotlin formatting + linting via ktlint (config:
# web/android/.editorconfig). The wrapper no-ops when ktlint is absent,
# so local machines without ktlint installed skip cleanly. CI installs
# ktlint before running pre-commit, so the check is enforced there.
# Install locally with `brew install ktlint` (macOS) or download from
# https://github.com/pinterest/ktlint/releases.
- id: android-ktlint-format
name: android ktlint format
language: system
entry: web/android/bin/ktlint.sh --format
files: ^web/android/.*\.kts?$
exclude: ^web/android/(build|\.gradle)/
- id: android-ktlint-check
name: android ktlint check
language: system
entry: web/android/bin/ktlint.sh
files: ^web/android/.*\.kts?$
exclude: ^web/android/(build|\.gradle)/
# iOS Swift formatting + linting via Apple's `swift format` (config:
# web/ios/.swift-format). The wrapper no-ops when the Swift toolchain
# is absent, so these run on macOS dev machines but skip the ubuntu-latest
+4
View File
@@ -5,6 +5,10 @@ generated at release time from each PR's `## Changelog` section, tagged by the
PR's `Type of change` (e.g. `[UI]`); the concise, curated highlights live on the
website under `/releases`.
## [v0.4.0] — 2026-07-03
Highlights and full notes: <https://github.com/omnigent-ai/omnigent/releases/tag/v0.4.0>
## [v0.3.0] — 2026-06-26
Highlights and full notes: <https://github.com/omnigent-ai/omnigent/releases/tag/v0.3.0>
+5 -1
View File
@@ -179,7 +179,7 @@ mirrors work out of the box; override with `OMNIGENT_INDEX_URL` if needed.
also launches a local web UI at `http://localhost:6767` that shows the same
session in the browser, or on a phone on your network (step 4). The
[desktop app](https://omnigent.ai/docs/interact/desktop) wraps that same UI
in a native window and adds OS notifications and a dock badge —
in a native window and adds OS notifications (with a configurable sound) and a dock badge —
[download it for macOS](https://omnigent.ai/download/mac).
> [!NOTE]
@@ -451,6 +451,10 @@ Polly at [`examples/polly/`](https://github.com/omnigent-ai/omnigent/tree/main/e
Contributions are welcome. See [CONTRIBUTING.md](https://github.com/omnigent-ai/omnigent/blob/main/CONTRIBUTING.md) for how to set up your environment, run the checks, and open a pull request.
Adding or changing support for a harness (Claude, Codex, Cursor, OpenCode,
Hermes, Pi, ...)? Run the [harness test bench](https://github.com/omnigent-ai/omnigent/tree/main/tests/harness_bench)
to check its capability matrix against observed behavior.
### Contributors
+8
View File
@@ -143,6 +143,14 @@ POSTGRES_PASSWORD=change-me-please
# ── Optional OIDC tuning ─────────────────────────────────
# OMNIGENT_OIDC_SESSION_TTL_HOURS=8
# OMNIGENT_OIDC_LOGOUT_REDIRECT_URI=https://omnigent.example.com/
#
# Skip the email_verified claim check on id_tokens. Some IdPs (e.g.
# Okta without custom API Access Management) omit the claim for
# directory-provisioned users, which otherwise fails login with
# "Could not determine user email". Only enable when the issuer is a
# trusted enterprise directory — it makes any signed email claim the
# user's identity. Off by default.
# OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION=1
# ── Server config file (admins, allowed domains, …) ──────
# Non-secret settings live in a YAML config file — the same one
+7 -4
View File
@@ -211,7 +211,7 @@ RUN apt-get update \
# user-namespace remapping the sandbox user maps to an unprivileged, unused
# host id. Unused by the root-based providers.
RUN groupadd -g 1000660000 sandbox \
&& useradd -m -u 1000660000 -g sandbox sandbox
&& useradd -m -d /sandbox -u 1000660000 -g sandbox sandbox
# Git credential helper for private repositories over HTTPS: answers
# `git credential get` from GIT_TOKEN / GIT_USERNAME in the
@@ -328,11 +328,14 @@ RUN set -eu; \
fi; \
echo "agy ${AGY_VERSION} pinned (sha256 verified)"
# Preserve /build/ — the venv's editable install .pth files reference
# /build/omnigent and /build/sdks/* by absolute path. Copying these to
# /app/ would break the import paths silently.
# Copy the venv and source tree. The editable install's .pth files reference
# /build/omnigent and /build/sdks/* -- both denied by the k8s Landlock LSM
# policy. Re-install without -e so the package bytes land in the venv's
# site-packages and imports no longer require /build at runtime.
COPY --from=builder /opt/venv /opt/venv
COPY --from=builder /build /build
RUN pip install --no-cache-dir /build /build/sdks/python-client /build/sdks/ui \
&& ! grep -R --include='*.pth' --include='*.egg-link' -nE '/build(/|$)' /opt/venv/lib/python*/site-packages
# Sandbox launchers exec commands through `bash -lc`, and Debian's
# /etc/profile unconditionally resets PATH for login shells — the ENV
+7
View File
@@ -40,3 +40,10 @@ allowed_domains:
# Extra Python modules scanned for POLICY_REGISTRY lists at startup.
# policy_modules:
# - myorg.policies.safety
# Copy-at-spawn limits. When a parent agent forwards files to a subagent,
# the server copies them through the destination session. These bound a
# single copy request so it can't spike shared-server memory; omit to use
# the built-in defaults (20 files / 256 MiB total).
# copy_max_files: 20
# copy_max_total_bytes: 268435456
+4
View File
@@ -97,6 +97,10 @@ services:
OMNIGENT_OIDC_SESSION_TTL_HOURS: "${OMNIGENT_OIDC_SESSION_TTL_HOURS:-8}"
OMNIGENT_OIDC_ALLOWED_DOMAINS: "${OMNIGENT_OIDC_ALLOWED_DOMAINS:-}"
OMNIGENT_OIDC_LOGOUT_REDIRECT_URI: "${OMNIGENT_OIDC_LOGOUT_REDIRECT_URI:-}"
# Skip the email_verified id_token check — for IdPs (e.g. Okta
# without API Access Management) that omit the claim for
# directory-provisioned users. Off unless set; see .env.example.
OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION: "${OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION:-}"
# Opt-in OIDC invites (admin pre-authorizes one off-domain email).
# Off unless set. The admin list (/data/admins) and the optional
# allowed-domains file (/data/allowed_domains) need no env var —
+4 -5
View File
@@ -249,13 +249,12 @@ The `overlays/sandbox-runners/` overlay turns on the **`kubernetes`** managed
sandbox provider: a `host_type: managed` session spawns one runner Pod that runs
`omnigent host` as its entrypoint and dials back over the launch-token tunnel. It
adds a dedicated runner namespace, a least-privilege server SA (scoped Pod +
Secret rights, **no `pods/exec`**), and the `sandbox:` server config. The server
image must be built with the `kubernetes` extra
(`--build-arg OMNIGENT_EXTRAS=kubernetes`). See
`overlays/sandbox-runners/README.md` for the full guide.
Secret rights, **no `pods/exec`**), and the `sandbox:` server config. The
overlay swaps in the official `omnigent-server-kubernetes` image variant, which
adds the `kubernetes` client extra the provider imports (the base server image
omits it). See `overlays/sandbox-runners/README.md` for the full guide.
```bash
# set the server image in overlays/sandbox-runners/kustomization.yaml first
kubectl apply -k deploy/kubernetes/overlays/sandbox-runners
# then create the omnigent-creds harness Secret (see the overlay README)
```
@@ -42,10 +42,11 @@ the generated runner Pod is already restricted-compliant (non-root uid 1000, dro
## Prerequisites
1. **A server image built with the `kubernetes` extra.** The base image omits
it, so `_ensure_sdk()` would fail every launch. Build with
`--build-arg OMNIGENT_EXTRAS=kubernetes` (see `deploy/docker`) and set the
image in `kustomization.yaml` (`images:` `newName`/`newTag`).
1. **A server image built with the `kubernetes` extra.** The overlay's
`images:` block already points at the official `omnigent-server-kubernetes`
variant, which includes it — nothing to build. If you self-build instead,
keep `kubernetes` in `OMNIGENT_EXTRAS` (see `deploy/docker`) or
`_ensure_sdk()` fails every launch, and point `images:` at your build.
2. **Harness credentials.** The runners read their LLM / git credentials from a
Secret named by `secret_name` (default `omnigent-creds`); you create it out of
band after applying the overlay — see step 2 of **Apply**. It is deliberately
@@ -130,9 +131,9 @@ writing nothing to disk — use HTTPS repository URLs. Details by provider match
| `namespace` | Runner-Pod namespace (defaults to `omnigent-sandboxes`). |
| `secret_name` | Harness-creds Secret projected into every Pod via `envFrom`. |
| `service_account` | ServiceAccount the runner Pods run as (powerless). |
| `image` | Optional runner image override (defaults to the official amd64 host image). |
| `image` | Optional runner image override (defaults to the official multi-arch amd64/arm64 host image). |
| `env` | Optional list of SERVER env-var names to inject as literal Pod env (prefer `secret_name` for credentials). |
| `node_selector` | Optional extra node labels, merged with the mandatory `kubernetes.io/arch: amd64`. |
| `node_selector` | Optional extra node labels, merged with a default `kubernetes.io/arch: amd64` — set that key to `arm64` to schedule runners on arm64 nodes. (arm64 note: the CEL policy module is unavailable there — `cel-expr-python` ships no aarch64 wheel — and degrades gracefully.) |
| `resources` | Optional `requests` / `limits` (`cpu` / `memory`) override. |
| `in_cluster` | Optional cluster-config source: `true` (in-cluster SA only), `false` (kubeconfig only), omit (try in-cluster, then kubeconfig). |
| `kubeconfig` | Optional kubeconfig path for the out-of-cluster fallback (env: `OMNIGENT_KUBERNETES_KUBECONFIG`). |
@@ -17,13 +17,14 @@ resources:
# omnigent-creds) is NOT checked in — create it out of band like the base
# OIDC secret (see README.md "Apply"). Prefer sealed-secrets/external-secrets.
# The base server image lacks the `kubernetes` extra, so a managed launch would
# fail to import the client. Build the server WITH it
# (`--build-arg OMNIGENT_EXTRAS=kubernetes`, see deploy/docker) and set it here.
# Use the server image variant that includes the kubernetes client extra
# (built by CI with OMNIGENT_EXTRAS=kubernetes). The base image omits it, so a
# managed launch there fails to import the client. Self-builds must keep
# `kubernetes` in OMNIGENT_EXTRAS (see deploy/docker); point newName at such a
# build here if you use one.
images:
- name: ghcr.io/omnigent-ai/omnigent-server
newName: ghcr.io/REPLACE_ME/omnigent-server
newTag: kubernetes
newName: ghcr.io/omnigent-ai/omnigent-server-kubernetes
patches:
- path: deployment-patch.yaml
@@ -28,9 +28,9 @@ data:
# ServiceAccount the runner Pods run as (deliberately powerless).
service_account: omnigent-runner
# ── all optional below ──
# image: ghcr.io/your-org/omnigent-host:latest # default: official amd64 host image
# image: ghcr.io/your-org/omnigent-host:latest # default: official multi-arch (amd64/arm64) host image
# env: [PROXY_URL] # SERVER env vars injected as literal Pod env (prefer secret_name for creds)
# node_selector: # extra node labels, merged with the mandatory kubernetes.io/arch: amd64
# node_selector: # extra node labels; default kubernetes.io/arch: amd64, override to arm64 to run there
# disktype: ssd
# resources: # runner Pod sizing (defaults: 0.5-2 cpu / 1-4Gi)
# requests: {cpu: "500m", memory: "1Gi"}
+14 -2
View File
@@ -73,10 +73,22 @@ Replaces `manifest._P0_ALL_SUPPORTED`:
| Bench probe | Backing capability | Declared verdict rule |
|---|---|---|
| `interrupt` | `interrupt: bool` | `True``SUPPORTED`, else `PARTIAL`/`UNSUPPORTED` |
| `streaming` | `streaming: bool` | `True``SUPPORTED` (deltas), else `PARTIAL` (complete-only) |
| `interrupt` | `interrupt: bool` | `True``SUPPORTED`, `False``UNSUPPORTED` |
| `streaming` | `streaming: bool` | `True``SUPPORTED` (deltas), `False``UNSUPPORTED` (see note) |
| `model_override` | `SDK_MODEL_OVERRIDE_HARNESSES` (already in the registry via `model_env_keys()`) or `native` metadata | already derivable from #1756; no new field |
> **Correction (implemented, supersedes the original `False → PARTIAL` idea).**
> `streaming` is **binary**: `False → UNSUPPORTED`, not `PARTIAL`. `PARTIAL`
> is a *probe observation only* — the streaming probe returns it for the
> ambiguous coalesced-single-delta case against a `SUPPORTED` declaration — and
> is **never a declared value**. Declaring a non-streaming harness `PARTIAL`
> drifts against reality, because the probe reports zero deltas as
> `UNSUPPORTED`. This was found live: kiro/cursor/qwen-native observe 0 deltas
> and are declared `False → UNSUPPORTED` (no drift). The rule now: **declare
> `streaming=False` only from a live observation of 0 deltas** — a static
> "the forwarder posts no delta" grep is not sufficient (pi-native has no
> delta-posting forwarder yet streams live).
### C. Probe-only — no capability backing; leave hand-declared
These are behaviors with no single trait to key off. Keep them in the manifest
as-is (or a small explicit table):
+1
View File
@@ -0,0 +1 @@
"""Performance benchmarks (runnable via ``uv run``, not shipped)."""
+243
View File
@@ -0,0 +1,243 @@
# Omnigent performance benchmark
Baseline, repeatable latency/throughput numbers for key Omnigent user
journeys, so we can track them over time and catch regressions. Modeled on
MLflow's `dev/benchmarks/gateway/` workflow.
The harness boots a real `omnigent server`, drives the selected journeys under
load, prints latency/throughput tables, and writes a versioned JSON report.
Two families: **HTTP/API journeys** (server + DB, no runner/LLM — fast and
low-noise) and **full-turn journeys** (a real agent turn through the runner +
a zero-latency mock LLM). See *Journeys* below.
By default the server boots a fresh, empty SQLite DB, which gives best-case
numbers that don't move with load. For meaningful results, point it at a
**pre-seeded corpus** (`seed.py`) and, ideally, at **Postgres** — production
runs on Databricks Lakebase (Postgres), whose per-query round-trip + pooling
cost SQLite doesn't have. See *Seeding* and *Backends* below.
## Run it
```bash
# All journeys, sequential latency (100 iterations × 3 runs each).
uv run --no-sync dev/benchmarks/omnigent/run.py
# A subset, writing a report for CI artifact upload.
uv run --no-sync dev/benchmarks/omnigent/run.py \
--journeys list_sessions,load_conversation_history \
--iterations 200 --runs 3 --output bench.json
# Throughput mode: >1 concurrency drives concurrency-safe journeys as load.
uv run --no-sync dev/benchmarks/omnigent/run.py \
--requests 500 --concurrency 25 --runs 3
# CI gating: exit 1 if a threshold is breached.
uv run --no-sync dev/benchmarks/omnigent/run.py --max-p50-ms 25 --max-p99-ms 100
```
`--no-sync` runs against the already-installed venv. (A bare `uv run` may try to
rebuild the project, which fails in a git worktree without a Node web-UI build;
`OMNIGENT_SKIP_WEB_UI=true uv sync` prepares the venv once, then use
`--no-sync`.)
Key flags (`--help` for all): `--journeys A,B`, `--database-uri URI` (seeded
corpus / Postgres; default: throwaway empty SQLite), `--iterations N` (per
latency run), `--requests N` / `--concurrency N` (throughput), `--runs N`,
`--warmup N`, `--output FILE`, `--min-rps` / `--max-p50-ms` / `--max-p99-ms`
(CI thresholds).
## Journeys
### HTTP/API (server + DB, runner-free)
| Journey | Operation timed | Stressed by |
| --- | --- | --- |
| `list_sessions` | `GET /v1/sessions` — session-list read | session count |
| `create_session` | `POST /v1/sessions` then `DELETE` — session create | write path |
| `get_session` | `GET /v1/sessions/{id}` — single-session snapshot | (O(1)) |
| `load_conversation_history` | `GET /v1/sessions/{id}/items` — history read | items/session |
| `search_sessions` | `GET /v1/sessions?search_query=` — unindexed `LIKE` | total item count |
| `fork_session` | `POST /v1/sessions/{id}/fork` — fork (deep-copy items); forks deleted in teardown, untimed | items/session |
| `add_comment` | `POST /v1/sessions/{id}/comments` — create a review comment | write path |
Read journeys target a **pre-seeded** session when the DB has a corpus; against
an empty DB they self-seed a small fallback session over HTTP (the
`external_conversation_item` event — appends items without starting a task), so
they still work with no runner or LLM.
### Full-turn (runner + mock LLM)
These drive a real agent turn end-to-end — `POST …/events` → server → **runner**
→ in-process executor → mock LLM → stream back → `idle`. Selecting any of them
boots `BenchEnvironment(with_runner=True)` automatically.
Each turn costs ~1 s+ (vs. the millisecond HTTP journeys), so these journeys
cap their latency iterations (`Journey.max_iterations`, currently 5) — a large
`--iterations` tuned for the HTTP journeys is clamped down for them so the run
stays within the CI time budget, with `--runs` providing the repeats. The cap
only lowers the count, never raises it. A cold start never deletes its session,
so sessions accumulate across a run; keeping the count small also keeps that
drift negligible (~2 ms/turn).
| Journey | Operation timed |
| --- | --- |
| `session_cold_start` | Create+bind a fresh session and drive its first turn to `idle` (runner spawn + executor construction + turn) |
| `warm_turn` | Drive a turn on an already-warm session — steady-state dispatch overhead |
| `time_to_first_token` | Post a turn; time to the first streamed `output_text` delta |
| `interrupt` | Interrupt a running (gated) turn; time to cancellation |
| `read_runner_file` | `GET .../environments/default/filesystem/{path}` — server → runner filesystem read proxy |
`read_runner_file` needs a runner but does **not** drive a turn or call the LLM:
its setup plants a file via `PUT`, and the timed op is the proxied read (a
localhost round-trip). Being far cheaper than a turn, it uses a higher iteration
cap (50) than the full-turn journeys.
**Only measure what we control.** Full-turn journeys always use the
**`openai-agents`** SDK harness, which runs **in-process** (a call into the
`agents` library + an HTTP call to the mock LLM) — no vendor binary, no external
process. Native harnesses (e.g. `claude-native`) launch the real vendor CLI
into a tmux pane, whose startup we don't control, so they're deliberately
excluded. The mock LLM is zero-latency, so every number is omnigent
dispatch/streaming/cancel overhead, not model latency.
Add a journey by registering a `Journey` in `journeys.py` (set `needs_runner`
for full-turn journeys).
## Seeding a realistic corpus
`seed.py` writes a sizeable, deterministic corpus directly through the store
API (no HTTP, no runner) into the same DB the server then boots against:
```bash
# Seed 5000 sessions × 50 items into a SQLite file, then benchmark against it.
uv run --no-sync dev/benchmarks/omnigent/seed.py \
--database-uri sqlite:////abs/path/bench.db --sessions 5000 --items-per-session 50
uv run --no-sync dev/benchmarks/omnigent/run.py \
--database-uri sqlite:////abs/path/bench.db --output bench.json
```
Seeding is **idempotent**: a matching corpus (same sessions/items/schema) is
detected and reused, so re-running is a fast no-op — pass `--reseed` to force,
or a differing config to be warned. SQLite absolute paths need four slashes
(`sqlite:////abs/...`). The reuse marker records the DB's Alembic head read at
seed time, so a corpus from an older schema is automatically reseeded — no
manual revision bookkeeping. `test_seed_creates_listable_corpus` (which seeds
through the store, running migrations to the current head) is the safety net
that a schema change hasn't broken seeding.
## Backends
`--database-uri` selects the DB; the report's `backend` field (`sqlite` /
`postgres`) is derived from the URI scheme so results group by backend.
- **SQLite** (default) — in-process; fast, but not prod-representative.
- **Postgres** — `postgresql+psycopg://user@host:5432/db` (the fully-qualified
`+psycopg` form; the server CLI does not normalize a bare `postgresql://`).
Requires `psycopg[binary]` (the `databricks` extra). Matches prod's
round-trip/pooling profile. Stand up a local one with
`docker run -e POSTGRES_PASSWORD=… -p 5432:5432 postgres:16`.
## Output → Databricks → dashboard
The harness writes JSON only. Storage and charting live in Databricks:
```
run.py --output bench.json → GitHub Actions artifact → Databricks notebook (ETL) → Delta table → AI/BI dashboard
(this repo) (CI, follow-up) (workspace, yours)
```
The repo's contract is the **JSON schema** below. A workspace notebook (owned
outside this repo, modeled on MLflow's gateway ETL) pulls the CI artifacts via
the GitHub API, flattens each run's `summary` + `runs` + metadata, and
`saveAsTable`s into a Delta table the dashboard reads. `sample_output.json` is a
committed, faithful example so the notebook can be written against a real
document without running the harness.
### JSON schema (`schema.py`, `SCHEMA_VERSION`)
```jsonc
{
"schema_version": 1,
"generated_at": "<ISO-8601 UTC>",
"git_sha": "<HEAD sha>",
"git_branch": "<branch>",
"host": {"platform": "...", "python": "...", "cpu_count": 12},
"harness": "http-only",
"config": {"iterations": 100, "requests": 500, "concurrency": 1,
"runs": 3, "warmup": 10, "with_runner": false,
"backend": "sqlite"},
"journeys": {
"<journey name>": {
"kind": "latency" | "throughput",
"backend": "sqlite" | "postgres",
"runs": [ // one per --runs
{"n_success": N, "n_failures": N, "failures": {"HTTP 500": 1},
"wall_time_s": , "mean_ms": , "p50_ms": , "p95_ms": ,
"p99_ms": , "max_ms": , "rps": }
],
"summary": {"avg_mean_ms": , "avg_p50_ms": , "avg_p95_ms": ,
"avg_p99_ms": , "avg_rps": } // averaged across runs
}
}
}
```
The per-journey `summary` + `runs` shape mirrors MLflow's gateway benchmark, so
the same ETL flatten works — keyed by `journey` and `backend`. Bump
`SCHEMA_VERSION` on any breaking shape change so the notebook can branch on it.
## Layout
| File | Role |
| --- | --- |
| `run.py` | CLI orchestrator + entrypoint |
| `seed.py` | deterministic corpus seeder (store API) |
| `journeys.py` | `Journey` dataclass, latency/throughput runners, registry |
| `environment.py` | server (± runner + mock LLM) lifecycle; `--database-uri` |
| `measure.py` | `RunResult`, percentile, aggregation, thresholds, tables |
| `schema.py` | `SCHEMA_VERSION`, `build_report`, git/host metadata |
| `sample_output.json` | committed example of the JSON contract |
The smoke test is `tests/benchmarks/test_benchmark_smoke.py` (boots the server
with tiny counts + a seeded-corpus unit test; runs on the normal CI lane, no
creds).
## CI
`.github/workflows/benchmark.yml` runs nightly (and on dispatch) as a backend
matrix — `sqlite` and `postgres` (a `postgres:16` service container). Each leg
seeds a corpus (SQLite reuses a cache keyed on the schema head + `seed.py` +
corpus config, so a migration busts the cache and forces a reseed; Postgres is
fresh per run), runs the benchmark, and uploads
`benchmark-results-<backend>-<run_id>.json`. The workspace notebook pulls those
artifacts.
Schema changes need no manual step: the seed always targets the current
migrated schema (migrations run when the store is constructed), the reuse
marker records the head read at seed time (so old corpora auto-reseed), and
`test_seed_creates_listable_corpus` fails if a migration genuinely breaks
seeding.
## Follow-ups
- **Subagent spawn.** A planned full-turn journey (`needs_runner=True`): the
parent agent emits a `sys_session_send` tool call, the runner dispatches a
child session, and the parent auto-wakes with the collected result. It's
fully mockable with the zero-latency mock LLM (no real model) — script the
parent's queue to emit the tool call and the child's queue to return a short
reply, then poll for the child's marker. It needs the parent bundle to declare
a sub-agent under `tools:` (extend `_agent_bundle`); the pattern is in
`tests/e2e/test_coder_subagent.py`.
- **Excluded journeys** (agent-behaviour-dependent, deliberately not measured):
multi-turn and tool-calling turns (dominated by the agent's own choices) and
large-history turns (the O(N) `history_to_input_items` conversion is real app
work but only fires on a cold runner cache, so isolating it entangles with
cold-start cost).
- **CI matrix.** Runner journeys are backend-agnostic (they exercise runner
dispatch, not big DB reads), so the nightly workflow can run them on the
SQLite leg only rather than both — wire a runner `--journeys` set into
`benchmark.yml` when desired.
- **Simulated provider latency.** The mock LLM returns at ~zero latency, which
is what isolates omnigent overhead. A fixed per-response delay knob would let
turns model end-user wall-clock instead; it's a small change behind the
`configure_mock` / `set_mock_fallback` seam if that's ever wanted.
+7
View File
@@ -0,0 +1,7 @@
"""Omnigent user-journey performance benchmark.
Stands up a real server + runner against a zero-latency mock LLM, drives
key user journeys under load, and emits a versioned JSON report of latency
percentiles and throughput. See ``README.md`` for the workflow and how the
workspace ETL notebook consumes the JSON.
"""
+694
View File
@@ -0,0 +1,694 @@
"""Benchmark environment lifecycle.
:class:`BenchEnvironment` is an async context manager that stands up a real
Omnigent ``server`` with no Databricks credentials. Two modes:
- ``with_runner=False`` (default): server + SQLite DB only. Enough for the
HTTP/API journeys, which never drive an agent turn.
- ``with_runner=True``: additionally spawns a zero-latency mock LLM and a
sibling ``runner``, routes the server-side prompt-policy classifier at the
mock (via ``--config``), and sets an ALLOW fallback — everything the
full-turn journeys need.
A full env is a strict superset of the HTTP-only env, so both modes share one
class; the runner mode is gated behind the flag rather than forked into a
separate type. It mirrors the proven ``live_server`` e2e recipe
(``tests/e2e/conftest.py``) and reuses the credential-free spawn core: the
compat helpers (so subprocesses import this worktree) and
``token_bound_runner_id``.
"""
from __future__ import annotations
import asyncio
import contextlib
import io
import os
import signal
import socket
import subprocess
import sys
import tarfile
import time
import uuid
from pathlib import Path
from typing import IO
import httpx
import yaml
from omnigent.runner.identity import OMNIGENT_INTERNAL_WS_ORIGIN, token_bound_runner_id
from tests._helpers.compat import (
apply_runner_env,
apply_server_env,
compat_runner_cwd,
compat_server_cwd,
runner_executable,
server_executable,
)
_REPO_ROOT = Path(__file__).resolve().parents[3]
_MOCK_SERVER = _REPO_ROOT / "tests" / "server" / "integration" / "mock_llm_server.py"
_HEALTH_TIMEOUT_S = 90.0
_MOCK_TIMEOUT_S = 15.0
_POLL_INTERVAL_S = 0.2
_TURN_TIMEOUT_S = 180.0
# Terminal SSE events — if one arrives before any delta, the turn produced no
# streamed text (a failure for the TTFT journey).
_STREAM_TERMINAL_EVENTS = frozenset(
{"response.completed", "response.failed", "response.cancelled"}
)
# The server persists an interrupted turn as a synthetic user message whose
# text contains this marker (see tests/e2e/test_cancel_history.py).
_CANCELLATION_MARKER = "interrupted"
# Default full-turn agent (with_runner=True). The mock ignores the model for
# routing (its "default" queue serves any request), but the key is baked into
# the spec so the harness has a concrete model to send.
_DEFAULT_MODEL = "mock-bench-brain"
_DEFAULT_HARNESS = "openai-agents"
# Server-side prompt-policy classifier queue key. In runner mode we set an
# ALLOW fallback here so a classifier call (if the agent trips one) never
# blocks or returns non-verdict text.
_POLICY_LLM_KEY = "_policy_llm_"
_POLICY_ALLOW = '{"action": "allow", "reason": ""}'
def _find_free_port() -> int:
"""Bind an ephemeral port and return it (races are tolerated by retries)."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
class BenchEnvironment:
"""Async context manager owning the benchmark's server (± runner + mock).
:param with_runner: When ``False`` (default), boot the server only — the
v1 HTTP-journey path. When ``True``, also spawn the mock LLM and a
runner and wire the policy classifier at the mock — the phase-2
full-turn path.
:param database_uri: SQLAlchemy URI the server boots against. ``None``
(default) uses a fresh throwaway SQLite file in the temp dir — the
empty-DB path. Pass a pre-seeded URI (e.g. a seeded SQLite file, or a
``postgresql+psycopg://…`` instance) to benchmark against a realistic
corpus. Postgres must be the fully-qualified ``+psycopg`` form — the
server CLI does not normalize it.
:param harness: Harness for full-turn agents when ``with_runner`` (default
``openai-agents``, a base dependency needing no vendor CLI binary).
:param model: Model string baked into registered agent specs.
"""
def __init__(
self,
*,
with_runner: bool = False,
database_uri: str | None = None,
harness: str = _DEFAULT_HARNESS,
model: str = _DEFAULT_MODEL,
) -> None:
self.with_runner = with_runner
self.database_uri = database_uri
self.harness = harness
self.model = model
self.base_url = ""
self.mock_url = ""
self.runner_id = ""
self.client: httpx.AsyncClient | None = None
self._tmp = Path("/tmp") / f"omni-bench-{uuid.uuid4().hex[:8]}"
self._mock_proc: subprocess.Popen[bytes] | None = None
self._server_proc: subprocess.Popen[bytes] | None = None
self._runner_proc: subprocess.Popen[bytes] | None = None
self._log_handles: list[IO[bytes]] = []
self._agent_cache: dict[str, str] = {}
# ── lifecycle ────────────────────────────────────────────
async def __aenter__(self) -> BenchEnvironment:
await asyncio.to_thread(self._start)
self.client = httpx.AsyncClient(
base_url=self.base_url,
timeout=300.0,
headers={"Origin": OMNIGENT_INTERNAL_WS_ORIGIN},
)
if self.with_runner:
# ALLOW fallback so a server-side classifier call resolves against
# the mock (never api.openai.com) and returns a valid verdict.
await self._mock_post(
"/mock/set_fallback", {"key": _POLICY_LLM_KEY, "text": _POLICY_ALLOW}
)
return self
async def __aexit__(self, *exc: object) -> None:
if self.client is not None:
await self.client.aclose()
await asyncio.to_thread(self._stop)
def _start(self) -> None:
"""Spawn the server (± mock + runner) and block until ready."""
self._tmp.mkdir(mode=0o700, parents=True, exist_ok=True)
artifact_dir = self._tmp / "artifacts"
artifact_dir.mkdir(exist_ok=True)
if self.with_runner:
mock_port = _find_free_port()
self.mock_url = f"http://127.0.0.1:{mock_port}"
self._mock_proc = self._spawn_mock(mock_port)
self._wait_mock_ready()
port = _find_free_port()
self.base_url = f"http://localhost:{port}"
binding_token = uuid.uuid4().hex
base_env = {**os.environ}
if self.with_runner:
self.runner_id = token_bound_runner_id(binding_token)
base_env["OPENAI_API_KEY"] = "mock-key"
# The OpenAI SDK appends /responses, so include /v1 in the base.
base_env["OPENAI_BASE_URL"] = f"{self.mock_url}/v1"
# Prepend the worktree so subprocesses import this branch's source.
apply_server_env(base_env, _REPO_ROOT)
self._server_proc = self._spawn_server(port, base_env, binding_token, artifact_dir)
if self.with_runner:
self._runner_proc = self._spawn_runner(base_env, binding_token)
self._wait_ready()
def _stop(self) -> None:
"""Terminate runner, server, and mock; remove the temp dir."""
for proc in (self._runner_proc, self._server_proc, self._mock_proc):
if proc is not None and proc.poll() is None:
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=8)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
for handle in self._log_handles:
handle.close()
import shutil
shutil.rmtree(self._tmp, ignore_errors=True)
# ── spawns ───────────────────────────────────────────────
def _log(self, name: str) -> IO[bytes]:
handle = (self._tmp / name).open("wb")
self._log_handles.append(handle)
return handle
def _spawn_mock(self, port: int) -> subprocess.Popen[bytes]:
return subprocess.Popen(
[sys.executable, str(_MOCK_SERVER), str(port)],
env={**os.environ, "PYTHONPATH": str(_REPO_ROOT)},
stdout=self._log("mock.log"),
stderr=subprocess.STDOUT,
)
def _spawn_server(
self,
port: int,
base_env: dict[str, str],
binding_token: str,
artifact_dir: Path,
) -> subprocess.Popen[bytes]:
# Pre-seeded URI when given (realistic corpus), else a throwaway SQLite
# file in the temp dir (the empty-DB path). SQLite absolute paths need
# four slashes; the temp path is absolute.
db_uri = self.database_uri or f"sqlite:///{self._tmp / 'bench.db'}"
args = [
server_executable(),
"-m",
"omnigent.cli",
"server",
"--port",
str(port),
"--database-uri",
db_uri,
"--artifact-location",
str(artifact_dir),
]
env = {**base_env}
if self.with_runner:
# Route the server-side policy-classifier LLM at the mock, mirroring
# live_server. Without this the classifier's client defaults to
# api.openai.com and errors. Server-only mode needs no llm config —
# the classifier only builds under OMNIGENT_SMART_ROUTING=1.
server_cfg = self._tmp / "server.yaml"
server_cfg.write_text(
yaml.safe_dump(
{
"llm": {
"model": _POLICY_LLM_KEY,
"connection": {
"base_url": f"{self.mock_url}/v1",
"api_key": "mock-key",
},
}
}
)
)
args.extend(["--config", str(server_cfg)])
env["OMNIGENT_RUNNER_TUNNEL_TOKEN"] = binding_token
return subprocess.Popen(
args,
env=env,
cwd=compat_server_cwd(),
stdout=self._log("server.log"),
stderr=subprocess.STDOUT,
)
def _spawn_runner(
self, base_env: dict[str, str], binding_token: str
) -> subprocess.Popen[bytes]:
# Point the runner's filesystem workspace at the temp dir so file
# writes (e.g. read_runner_file's setup) land there and are cleaned up
# on teardown, rather than in the launch cwd (its default).
workspace = self._tmp / "workspace"
workspace.mkdir(exist_ok=True)
runner_env = apply_runner_env(
{
**base_env,
"OMNIGENT_RUNNER_ID": self.runner_id,
"OMNIGENT_RUNNER_TUNNEL_BINDING_TOKEN": binding_token,
"OMNIGENT_RUNNER_PARENT_PID": str(os.getpid()),
"RUNNER_SERVER_URL": self.base_url,
"OMNIGENT_RUNNER_WORKSPACE": str(workspace),
}
)
return subprocess.Popen(
[runner_executable(), "-m", "omnigent.runner._entry"],
env=runner_env,
cwd=compat_runner_cwd(),
stdout=self._log("runner.log"),
stderr=subprocess.STDOUT,
)
# ── readiness ────────────────────────────────────────────
def _wait_mock_ready(self) -> None:
deadline = time.monotonic() + _MOCK_TIMEOUT_S
while time.monotonic() < deadline:
try:
if httpx.get(f"{self.mock_url}/stats", timeout=1).status_code == 200:
return
except httpx.HTTPError:
pass
time.sleep(0.1)
raise RuntimeError(f"mock LLM not ready within {_MOCK_TIMEOUT_S}s; logs in {self._tmp}")
def _wait_ready(self) -> None:
"""Wait for ``/health`` (and, in runner mode, the runner online)."""
deadline = time.monotonic() + _HEALTH_TIMEOUT_S
while time.monotonic() < deadline:
try:
health = httpx.get(f"{self.base_url}/health", timeout=2)
if health.status_code == 200 and self._runner_ready():
return
except httpx.HTTPError:
pass
time.sleep(_POLL_INTERVAL_S)
raise RuntimeError(f"server not ready within {_HEALTH_TIMEOUT_S}s; logs in {self._tmp}")
def _runner_ready(self) -> bool:
"""Whether the runner reports online (always ``True`` server-only)."""
if not self.with_runner:
return True
status = httpx.get(f"{self.base_url}/v1/runners/{self.runner_id}/status", timeout=2)
return status.status_code == 200 and status.json().get("online") is True
# ── mock control (runner mode only) ──────────────────────
async def _mock_post(self, path: str, body: dict[str, object]) -> None:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.post(f"{self.mock_url}{path}", json=body)
resp.raise_for_status()
async def configure_mock(
self,
responses: list[dict[str, object]],
*,
key: str = "default",
match: str | None = None,
) -> None:
"""Load a keyed response queue on the mock (see e2e ``configure_mock_llm``)."""
payload: dict[str, object] = {"key": key, "responses": responses}
if match is not None:
payload["match"] = match
await self._mock_post("/mock/configure", payload)
async def set_mock_fallback(
self, text: str, *, key: str = "default", stream: bool = False
) -> None:
"""Set a reset-surviving fallback response for a mock queue *key*.
:param stream: When ``True`` the fallback emits per-word
``output_text.delta`` events before completing — needed for the
time-to-first-token journey to observe streamed deltas.
"""
await self._mock_post("/mock/set_fallback", {"key": key, "text": text, "stream": stream})
# ── agent + session primitives ───────────────────────────
def _agent_bundle(self, name: str) -> bytes:
"""Build a ``spec_version: 1`` agent bundle.
In runner mode the executor is wired at the mock LLM (auth +
connection). Server-only, no LLM is ever called, so the bundle just
needs to be a valid spec the server can register and bind sessions to.
"""
executor: dict[str, object] = {
"type": "omnigent",
"model": self.model,
"config": {"harness": self.harness},
}
config: dict[str, object] = {
"spec_version": 1,
"name": name,
"prompt": "You are a helpful assistant used for performance benchmarking.",
"executor": executor,
}
if self.with_runner:
executor["auth"] = {
"type": "api_key",
"api_key": "mock-key",
"base_url": f"{self.mock_url}/v1",
}
executor["connection"] = {"base_url": f"{self.mock_url}/v1", "api_key": "mock-key"}
# A filesystem env so the runner can serve the resource endpoints
# (read_runner_file). Without os_env the runner has no primary
# environment to materialize and the filesystem proxy 404s.
# sandbox.type=none avoids needing a bwrap binary on the host.
config["os_env"] = {
"type": "caller_process",
"cwd": ".",
"sandbox": {"type": "none"},
}
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
payload = yaml.safe_dump(config).encode()
info = tarfile.TarInfo("config.yaml")
info.size = len(payload)
tar.addfile(info, io.BytesIO(payload))
return buf.getvalue()
async def ensure_agent(self, name: str = "bench-agent") -> str:
"""Register the benchmark agent once, returning its name (idempotent)."""
assert self.client is not None
if name in self._agent_cache:
return name
resp = await self.client.post(
"/v1/sessions",
data={"metadata": "{}"},
files={"bundle": ("agent.tar.gz", self._agent_bundle(name), "application/gzip")},
)
if resp.status_code not in (200, 201, 409):
raise RuntimeError(f"agent register failed: {resp.status_code} {resp.text[:400]}")
self._agent_cache[name] = name
return name
async def agent_id(self, agent_name: str) -> str:
"""Resolve a registered agent's id by name."""
assert self.client is not None
listing = await self.client.get(
"/v1/sessions", params={"agent_name": agent_name, "limit": 1}
)
listing.raise_for_status()
return str(listing.json()["data"][0]["agent_id"])
async def create_session(self, agent_id: str) -> str:
"""Create an (unbound) session for *agent_id*, returning its id."""
assert self.client is not None
created = await self.client.post("/v1/sessions", json={"agent_id": agent_id})
created.raise_for_status()
return str(created.json()["id"])
async def seed_items(self, session_id: str, count: int) -> None:
"""Append *count* history items over HTTP, with no runner or LLM.
Uses the ``external_conversation_item`` event, which the server
appends "without starting or steering a task" — the runner-free path
for giving ``load_conversation_history`` something to read back.
Items are user messages: assistant messages require an ``agent`` field
the server only has after a real turn, and the read path this seeds is
role-agnostic — item count and size, not role, drive its cost.
"""
assert self.client is not None
for i in range(count):
body = {
"type": "external_conversation_item",
"data": {
"item_type": "message",
"item_data": {
"role": "user",
"content": [{"type": "input_text", "text": f"benchmark seed item {i}"}],
},
},
}
resp = await self.client.post(f"/v1/sessions/{session_id}/events", json=body)
resp.raise_for_status()
# ── runner-mode session driving (phase 2) ────────────────
async def create_bound_session(self, agent_id: str) -> str:
"""Create a session for *agent_id* and bind it to the runner."""
assert self.client is not None
if not self.with_runner:
raise RuntimeError("create_bound_session requires with_runner=True")
session_id = await self.create_session(agent_id)
bound = await self.client.patch(
f"/v1/sessions/{session_id}", json={"runner_id": self.runner_id}
)
bound.raise_for_status()
return session_id
async def write_runner_file(self, session_id: str, relative_path: str, content: str) -> None:
"""Write a file into the runner's default environment over HTTP.
The server proxies the ``PUT`` to the bound runner, which writes to its
sandboxed filesystem — so this needs a runner. Used to plant a file the
read journey can then fetch back.
:raises RuntimeError: If not in runner mode.
"""
assert self.client is not None
if not self.with_runner:
raise RuntimeError("write_runner_file requires with_runner=True")
resp = await self.client.put(
f"/v1/sessions/{session_id}/resources/environments/default/filesystem/{relative_path}",
json={"content": content, "encoding": "utf-8"},
)
resp.raise_for_status()
async def read_runner_file(self, session_id: str, relative_path: str) -> None:
"""Read a file from the runner's default environment over HTTP.
Times the server → runner filesystem proxy (a localhost round-trip); no
LLM is involved. Requires a runner — the server returns 502 without one.
:raises RuntimeError: If not in runner mode.
"""
assert self.client is not None
if not self.with_runner:
raise RuntimeError("read_runner_file requires with_runner=True")
resp = await self.client.get(
f"/v1/sessions/{session_id}/resources/environments/default/filesystem/{relative_path}",
)
resp.raise_for_status()
async def drive_turn(
self, session_id: str, text: str, *, timeout: float = _TURN_TIMEOUT_S
) -> None:
"""Post a user message and poll the session to a terminal state.
:raises RuntimeError: If not in runner mode, the turn fails, or it does
not settle within *timeout* seconds.
"""
assert self.client is not None
if not self.with_runner:
raise RuntimeError("drive_turn requires with_runner=True")
body = {
"type": "message",
"data": {"role": "user", "content": [{"type": "input_text", "text": text}]},
}
posted = await self.client.post(f"/v1/sessions/{session_id}/events", json=body)
posted.raise_for_status()
deadline = time.monotonic() + timeout
seen_running = False
while time.monotonic() < deadline:
snap = await self.client.get(f"/v1/sessions/{session_id}")
snap.raise_for_status()
status = snap.json().get("status")
if status in ("running", "waiting"):
seen_running = True
elif status == "failed":
raise RuntimeError(f"turn failed: {snap.json().get('last_task_error')}")
elif status == "idle" and seen_running:
return
await asyncio.sleep(_POLL_INTERVAL_S)
raise RuntimeError(f"turn did not settle within {timeout}s (session {session_id})")
async def _wait_idle(self, session_id: str, *, timeout: float = _TURN_TIMEOUT_S) -> None:
"""Poll until the session is ``idle`` (a prior turn has settled)."""
assert self.client is not None
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
snap = await self.client.get(f"/v1/sessions/{session_id}")
snap.raise_for_status()
if snap.json().get("status") == "idle":
return
await asyncio.sleep(_POLL_INTERVAL_S)
raise RuntimeError(f"session did not reach idle within {timeout}s ({session_id})")
async def time_to_first_delta(
self, session_id: str, text: str, *, timeout: float = _TURN_TIMEOUT_S
) -> None:
"""Post a turn and return once the first output-text delta streams back.
The session SSE stream (``GET …/stream``) is separate from the message
POST, so we subscribe first (as a concurrent task), post the turn, then
return when the first ``response.output_text.delta`` event arrives. This
times omnigent's streaming-pipeline overhead to first token — with the
zero-latency mock there is no model latency in the number.
:raises RuntimeError: If not in runner mode, or no delta / a terminal
event arrives within *timeout*.
"""
assert self.client is not None
if not self.with_runner:
raise RuntimeError("time_to_first_delta requires with_runner=True")
connected = asyncio.Event()
first_delta = asyncio.Event()
outcome: dict[str, str] = {}
async def _read_stream() -> None:
try:
async with self.client.stream( # type: ignore[union-attr]
"GET", f"/v1/sessions/{session_id}/stream", timeout=timeout
) as resp:
# Any first line means the SSE connection is live (the server
# emits a heartbeat on connect). Signalling here lets us post
# the turn only once subscribed — without a blind sleep that
# would otherwise inflate the measured time-to-first-delta.
connected.set()
async for line in resp.aiter_lines():
if not line.startswith("event:"):
continue
etype = line[len("event:") :].strip()
if etype == "response.output_text.delta":
first_delta.set()
return
if etype in _STREAM_TERMINAL_EVENTS:
outcome["terminal"] = etype
first_delta.set()
return
except httpx.HTTPError as exc:
outcome["error"] = repr(exc)
connected.set()
first_delta.set()
# Ensure any prior turn has settled so the fresh subscription's first
# terminal event can't be the previous turn completing (which would
# otherwise race ahead of this turn's delta).
await self._wait_idle(session_id, timeout=timeout)
reader = asyncio.create_task(_read_stream())
try:
# Wait until the stream is actually connected (not a fixed sleep) so
# the measured window is post → first delta, not subscription setup.
await asyncio.wait_for(connected.wait(), timeout=timeout)
posted = await self.client.post(
f"/v1/sessions/{session_id}/events",
json={
"type": "message",
"data": {"role": "user", "content": [{"type": "input_text", "text": text}]},
},
)
posted.raise_for_status()
try:
await asyncio.wait_for(first_delta.wait(), timeout=timeout)
except TimeoutError as exc:
raise RuntimeError(
f"no output_text.delta within {timeout}s (session {session_id})"
) from exc
if "error" in outcome:
raise RuntimeError(f"stream error: {outcome['error']}")
if "terminal" in outcome:
raise RuntimeError(
f"turn reached {outcome['terminal']} before any delta (session {session_id})"
)
finally:
reader.cancel()
async def drive_and_interrupt(
self, session_id: str, *, timeout: float = _TURN_TIMEOUT_S
) -> None:
"""Drive a gated turn, interrupt it mid-flight, return when cancelled.
The caller configures a ``block=True`` mock response first (see
:meth:`configure_mock`), so the turn parks in ``running`` on the
executor's LLM call. We post an ``interrupt`` once running, wait for the
server's cancellation marker, then release the gate so the runner
unwinds cleanly. Times the server → runner → executor cancel path.
:raises RuntimeError: If not in runner mode, or the interrupt is not
honored within *timeout*.
"""
assert self.client is not None
if not self.with_runner:
raise RuntimeError("drive_and_interrupt requires with_runner=True")
body = {
"type": "message",
"data": {"role": "user", "content": [{"type": "input_text", "text": "Interrupt me."}]},
}
posted = await self.client.post(f"/v1/sessions/{session_id}/events", json=body)
posted.raise_for_status()
deadline = time.monotonic() + timeout
interrupted = False
try:
while time.monotonic() < deadline:
snap = (await self.client.get(f"/v1/sessions/{session_id}")).json()
status = snap.get("status")
items = snap.get("items", [])
if status in ("running", "waiting") and not interrupted:
await self.client.post(
f"/v1/sessions/{session_id}/events", json={"type": "interrupt"}
)
interrupted = True
if _has_cancellation_marker(items):
return
if status == "idle" and interrupted:
if _has_cancellation_marker(items):
return
raise RuntimeError("turn settled without a cancellation marker")
await asyncio.sleep(_POLL_INTERVAL_S)
raise RuntimeError(f"interrupt not honored within {timeout}s (session {session_id})")
finally:
# Always release the gate so the blocked runner turn unwinds and
# teardown doesn't hang, even if the interrupt path errored above.
with contextlib.suppress(httpx.HTTPError):
await self._mock_post("/gate/release", {})
def _has_cancellation_marker(items: list[dict[str, object]]) -> bool:
"""Whether items include the synthetic 'interrupted' user message."""
for raw in items:
data = raw.get("data", raw)
if not isinstance(data, dict):
continue
if raw.get("type") == "message" and data.get("role") == "user":
content = data.get("content") or []
if isinstance(content, list) and any(
isinstance(b, dict) and _CANCELLATION_MARKER in str(b.get("text", ""))
for b in content
):
return True
return False
+559
View File
@@ -0,0 +1,559 @@
"""User-journey definitions and the runners that time them.
A :class:`Journey` names a user-facing operation, an optional per-journey
``setup`` that returns a context object, and a ``measure`` coroutine — the
timed unit. :func:`run_latency` times ``measure`` sequentially; journeys marked
``concurrency_safe`` can also be driven by :func:`run_throughput` with many
operations in flight.
v1 journeys are pure HTTP/API (server + DB, no runner, no LLM):
- ``list_sessions`` — the session-list read behind the sidebar/home.
- ``create_session`` — session creation cost (POST then DELETE).
- ``get_session`` — single-session snapshot load.
- ``load_conversation_history`` — history read, seeded runner-free via
``external_conversation_item`` (see :meth:`BenchEnvironment.seed_items`).
- ``fork_session`` — fork a session (deep-copy its items), then DELETE.
- ``add_comment`` — create a review comment on a file (DB write).
``read_runner_file`` needs a runner but no LLM turn: it plants a file in the
runner environment (setup) and times the server → runner filesystem read proxy.
The framework (``Journey`` + the two runners) is harness-agnostic and reused
verbatim by phase-2 full-turn journeys.
"""
from __future__ import annotations
import asyncio
import contextlib
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Literal, cast
import httpx
from .environment import BenchEnvironment
from .measure import RunResult
# Per-journey context returned by ``setup`` and threaded to ``measure``. Its
# concrete type varies by journey (an agent id, a session id, or nothing), so
# it is opaque at the framework level; each measure op casts it as needed.
JourneyContext = object
JourneyKind = Literal["latency", "throughput"]
# Items requested per history-read page. Also the count self-seeded into a
# fallback session when the DB has no corpus (empty-DB smoke path).
_HISTORY_PAGE_LIMIT = 20
_HISTORY_SEED_ITEMS = _HISTORY_PAGE_LIMIT
@dataclass
class Journey:
"""One benchmarkable user journey.
:param name: Stable identifier used on the CLI and as the report key.
:param kind: ``"latency"`` (time each operation) or ``"throughput"``
(fixed request count under concurrency). A latency journey that is
``concurrency_safe`` can additionally be run as throughput.
:param measure: Coroutine performing exactly one timed operation, given
the environment and the setup context.
:param setup: Optional coroutine run once before timing; its return value
is passed to ``measure`` (and ``teardown``) as ``ctx``.
:param teardown: Optional coroutine run once after timing, given ``ctx``.
:param concurrency_safe: Whether many ``measure`` calls may run at once
against a shared setup (true for read-only / independent-write HTTP
journeys).
:param needs_runner: Whether this journey drives a full agent turn and so
requires ``BenchEnvironment(with_runner=True)`` (mock LLM + runner).
HTTP/DB journeys leave this ``False``.
:param max_iterations: Upper bound on latency iterations for this journey,
clamping ``--iterations`` down (never up). Full-turn journeys cost ~1s+
per op, so 100+ iterations would blow the CI time budget; they cap at a
few samples per run and lean on ``--runs`` for repeats. ``None`` (HTTP
journeys) means no cap.
:param description: Human-readable one-liner for ``--list``.
"""
name: str
kind: JourneyKind
measure: Callable[[BenchEnvironment, JourneyContext], Awaitable[None]]
setup: Callable[[BenchEnvironment], Awaitable[JourneyContext]] | None = None
teardown: Callable[[BenchEnvironment, JourneyContext], Awaitable[None]] | None = None
concurrency_safe: bool = False
needs_runner: bool = False
max_iterations: int | None = None
description: str = ""
async def run_setup(self, env: BenchEnvironment) -> JourneyContext:
return await self.setup(env) if self.setup is not None else None
async def run_teardown(self, env: BenchEnvironment, ctx: JourneyContext) -> None:
if self.teardown is not None:
await self.teardown(env, ctx)
# ── timed operation (shared by both runners) ─────────────────
async def _timed(
journey: Journey, env: BenchEnvironment, ctx: JourneyContext, result: RunResult
) -> None:
"""Run one ``measure`` op, recording its latency or a failure reason."""
start = time.perf_counter()
try:
await journey.measure(env, ctx)
except httpx.HTTPStatusError as exc:
result.record_failure(f"HTTP {exc.response.status_code}")
except Exception as exc: # noqa: BLE001 — any failure is a recorded data point
result.record_failure(exc.__class__.__name__)
else:
result.latencies_ms.append((time.perf_counter() - start) * 1000)
# ── runners ──────────────────────────────────────────────────
async def run_latency(
journey: Journey, env: BenchEnvironment, *, iterations: int, warmup: int
) -> RunResult:
"""Time *iterations* sequential operations after discarding *warmup*.
Warmup operations run through the same path but are excluded from the
result, so first-call import/JIT/connection costs don't skew the numbers.
"""
ctx = await journey.run_setup(env)
try:
for _ in range(warmup):
with contextlib.suppress(Exception): # warmup errors are non-fatal
await journey.measure(env, ctx)
result = RunResult()
wall_start = time.perf_counter()
for _ in range(iterations):
await _timed(journey, env, ctx, result)
result.wall_time = time.perf_counter() - wall_start
return result
finally:
await journey.run_teardown(env, ctx)
async def run_throughput(
journey: Journey,
env: BenchEnvironment,
*,
requests: int,
concurrency: int,
warmup: int,
) -> RunResult:
"""Fire *requests* operations with at most *concurrency* in flight.
Wall time spans from the first dispatch to the last completion, so
``throughput`` reflects sustained req/s under load (MLflow's ``_run_once``
shape, with an :class:`asyncio.Semaphore` gate).
"""
ctx = await journey.run_setup(env)
try:
sem = asyncio.Semaphore(concurrency)
async def _one(count_it: bool, result: RunResult) -> None:
async with sem:
if count_it:
await _timed(journey, env, ctx, result)
else:
with contextlib.suppress(Exception): # warmup errors are non-fatal
await journey.measure(env, ctx)
if warmup:
throwaway = RunResult()
await asyncio.gather(*[_one(False, throwaway) for _ in range(warmup)])
result = RunResult()
wall_start = time.perf_counter()
await asyncio.gather(*[_one(True, result) for _ in range(requests)])
result.wall_time = time.perf_counter() - wall_start
return result
finally:
await journey.run_teardown(env, ctx)
# ── journey implementations ──────────────────────────────────
#
# Setups return the context each measure op needs. Ops must be independent so
# concurrency-safe journeys don't interfere across in-flight calls.
# A token present in the seeded corpus (titles + item text, see seed.py
# _FRAGMENTS) so search_sessions exercises the LIKE path with real matches.
_SEARCH_TOKEN = "runner"
async def _setup_agent_id(env: BenchEnvironment) -> str:
"""Register the benchmark agent and return its id."""
name = await env.ensure_agent()
return await env.agent_id(name)
async def _setup_target_session(env: BenchEnvironment) -> str:
"""Return a session id to read: an existing corpus session if any, else make one.
Real runs target a pre-seeded corpus (``seed.py``), so we read a
representative existing session. When the DB is empty (e.g. the smoke test
against a throwaway DB), fall back to creating one with a little history so
the journey still exercises the read path.
"""
assert env.client is not None
listing = await env.client.get("/v1/sessions", params={"limit": 1})
listing.raise_for_status()
data = listing.json().get("data", [])
if data:
return str(data[0]["id"])
# Empty DB: self-seed one session over HTTP (runner-free).
name = await env.ensure_agent()
agent_id = await env.agent_id(name)
session_id = await env.create_session(agent_id)
await env.seed_items(session_id, _HISTORY_SEED_ITEMS)
return session_id
async def _measure_list_sessions(env: BenchEnvironment, _ctx: JourneyContext) -> None:
assert env.client is not None
resp = await env.client.get("/v1/sessions", params={"limit": 20})
resp.raise_for_status()
async def _measure_search_sessions(env: BenchEnvironment, _ctx: JourneyContext) -> None:
assert env.client is not None
resp = await env.client.get(
"/v1/sessions", params={"limit": 20, "search_query": _SEARCH_TOKEN}
)
resp.raise_for_status()
async def _measure_create_session(env: BenchEnvironment, ctx: JourneyContext) -> None:
assert env.client is not None
agent_id = cast(str, ctx) # _setup_agent_id
created = await env.client.post("/v1/sessions", json={"agent_id": agent_id})
created.raise_for_status()
# Delete inline so a long run doesn't accumulate unbounded sessions; the
# POST is the operation of interest and dominates the timed span.
session_id = created.json()["id"]
deleted = await env.client.delete(f"/v1/sessions/{session_id}")
deleted.raise_for_status()
async def _measure_get_session(env: BenchEnvironment, ctx: JourneyContext) -> None:
assert env.client is not None
session_id = cast(str, ctx) # _setup_target_session
resp = await env.client.get(f"/v1/sessions/{session_id}")
resp.raise_for_status()
async def _measure_load_history(env: BenchEnvironment, ctx: JourneyContext) -> None:
assert env.client is not None
session_id = cast(str, ctx) # _setup_target_session
resp = await env.client.get(
f"/v1/sessions/{session_id}/items",
params={"order": "asc", "limit": _HISTORY_PAGE_LIMIT},
)
resp.raise_for_status()
@dataclass
class _ForkContext:
"""Fork-journey context: the session to fork + the forks to clean up.
``measure`` records each fork's id here instead of deleting it inline, so
the DELETE stays out of the timed span; ``teardown`` removes them after.
"""
source_id: str
fork_ids: list[str]
async def _setup_fork_session(env: BenchEnvironment) -> _ForkContext:
"""Resolve a session to fork; start an empty fork-id collector."""
source_id = await _setup_target_session(env)
return _ForkContext(source_id=source_id, fork_ids=[])
async def _measure_fork_session(env: BenchEnvironment, ctx: JourneyContext) -> None:
assert env.client is not None
fork_ctx = cast(_ForkContext, ctx) # _setup_fork_session
forked = await env.client.post(f"/v1/sessions/{fork_ctx.source_id}/fork", json={})
forked.raise_for_status()
# Record the fork for teardown; deleting it here would fold the DELETE into
# the timed span. The fork POST (a deep-copy of the source's items) is the
# operation of interest.
fork_ctx.fork_ids.append(forked.json()["id"])
async def _teardown_fork_session(env: BenchEnvironment, ctx: JourneyContext) -> None:
"""Delete every fork created during the run (best effort, untimed)."""
assert env.client is not None
fork_ctx = cast(_ForkContext, ctx)
for fork_id in fork_ctx.fork_ids:
with contextlib.suppress(httpx.HTTPError):
await env.client.delete(f"/v1/sessions/{fork_id}")
# Anchor snapshot for the comment journey; the offsets below span it.
_COMMENT_ANCHOR = "benchmark"
async def _measure_add_comment(env: BenchEnvironment, ctx: JourneyContext) -> None:
assert env.client is not None
session_id = cast(str, ctx) # _setup_target_session
# Each POST creates an independent comment row. Unlike sessions, an
# accumulating comment skews no measured read path, so there's no cleanup.
# The file need not exist — the handler stores the path + offsets + body.
resp = await env.client.post(
f"/v1/sessions/{session_id}/comments",
json={
"path": "bench_target.py",
"body": "benchmark review comment",
"start_index": 0,
"end_index": len(_COMMENT_ANCHOR),
"anchor_content": _COMMENT_ANCHOR,
},
)
resp.raise_for_status()
# ── runner (full-turn) journeys ──────────────────────────────
#
# These drive a real agent turn through the runner + mock LLM (with_runner=True,
# openai-agents). The mock is zero-latency, so every number is omnigent dispatch
# / streaming / cancel overhead, not model latency. Short deterministic replies.
# A multi-word reply so the streaming path emits several output_text deltas.
_TURN_REPLY = "Hello there, this is a mock benchmark reply."
_TURN_PROMPT = "Say hello."
# Iteration cap for full-turn journeys. At ~1s+ per turn, matching the HTTP
# journeys' iteration count would overrun the CI time budget, so we take a few
# samples per run and lean on --runs for repeats. Sessions accumulate across a
# run (a cold start never deletes its session), so a small count also keeps that
# drift negligible.
_RUNNER_MAX_ITERATIONS = 5
# Iteration cap for the runner filesystem read. It's a proxied localhost read,
# not a full turn, so it's far cheaper than the drive-a-turn journeys — a higher
# cap gives a usable p50/p99 while staying well within the CI time budget.
_RUNNER_FS_MAX_ITERATIONS = 50
# File planted by the read-runner-file setup and fetched by its measure op.
# ~1 KB — a modest, representative source file, not a stress case.
_RUNNER_FILE_PATH = "bench_read_target.txt"
_RUNNER_FILE_CONTENT = "benchmark file content line\n" * 40
async def _setup_turn_agent(env: BenchEnvironment, *, stream: bool = False) -> str:
"""Register the agent + a reset-surviving reply; return the agent id.
The fallback survives per-call queue exhaustion, so every turn in the run
gets the same reply regardless of how many turns consume the queue. When
*stream* is set the reply emits per-word deltas (for the TTFT journey).
"""
name = await env.ensure_agent()
await env.set_mock_fallback(_TURN_REPLY, stream=stream)
return await env.agent_id(name)
async def _setup_warm_session(env: BenchEnvironment) -> str:
"""Create+bind a session and drive one warm-up turn; return the session id.
The warm-up pays the cold-start cost (runner spawn + executor construction)
so the measured op times only steady-state per-turn overhead.
"""
agent_id = await _setup_turn_agent(env)
session_id = await env.create_bound_session(agent_id)
await env.drive_turn(session_id, _TURN_PROMPT)
return session_id
async def _setup_streaming_session(env: BenchEnvironment) -> str:
"""Warm session whose mock reply streams deltas — for the TTFT journey."""
agent_id = await _setup_turn_agent(env, stream=True)
session_id = await env.create_bound_session(agent_id)
await env.drive_turn(session_id, _TURN_PROMPT)
return session_id
async def _setup_interrupt_session(env: BenchEnvironment) -> str:
"""Create+bind a session for the interrupt journey; return the session id.
Configures a ``block=True`` mock response so each turn parks in ``running``
until the gate is released — giving the interrupt something to cancel
mid-flight, deterministically.
"""
name = await env.ensure_agent()
agent_id = await env.agent_id(name)
session_id = await env.create_bound_session(agent_id)
await env.configure_mock([{"text": _TURN_REPLY, "block": True}])
return session_id
async def _measure_session_cold_start(env: BenchEnvironment, ctx: JourneyContext) -> None:
agent_id = cast(str, ctx) # _setup_turn_agent
session_id = await env.create_bound_session(agent_id)
await env.drive_turn(session_id, _TURN_PROMPT)
async def _measure_warm_turn(env: BenchEnvironment, ctx: JourneyContext) -> None:
session_id = cast(str, ctx) # _setup_warm_session
await env.drive_turn(session_id, _TURN_PROMPT)
async def _measure_time_to_first_token(env: BenchEnvironment, ctx: JourneyContext) -> None:
session_id = cast(str, ctx) # _setup_warm_session
await env.time_to_first_delta(session_id, _TURN_PROMPT)
async def _measure_interrupt(env: BenchEnvironment, ctx: JourneyContext) -> None:
session_id = cast(str, ctx) # _setup_interrupt_session
await env.drive_and_interrupt(session_id)
async def _setup_runner_file_session(env: BenchEnvironment) -> str:
"""Bind a session to the runner and plant a file to read; return its id.
No turn is driven and no mock reply is configured — the measured op is a
filesystem read proxied to the runner, which never calls the LLM.
"""
name = await env.ensure_agent()
agent_id = await env.agent_id(name)
session_id = await env.create_bound_session(agent_id)
await env.write_runner_file(session_id, _RUNNER_FILE_PATH, _RUNNER_FILE_CONTENT)
return session_id
async def _measure_read_runner_file(env: BenchEnvironment, ctx: JourneyContext) -> None:
session_id = cast(str, ctx) # _setup_runner_file_session
await env.read_runner_file(session_id, _RUNNER_FILE_PATH)
# ── registry ─────────────────────────────────────────────────
ALL_JOURNEYS: dict[str, Journey] = {
j.name: j
for j in (
Journey(
name="list_sessions",
kind="latency",
measure=_measure_list_sessions,
concurrency_safe=True,
description="GET /v1/sessions — session list read.",
),
Journey(
name="create_session",
kind="latency",
measure=_measure_create_session,
setup=_setup_agent_id,
concurrency_safe=True,
description="POST /v1/sessions then DELETE — session create.",
),
Journey(
name="get_session",
kind="latency",
measure=_measure_get_session,
setup=_setup_target_session,
concurrency_safe=True,
description="GET /v1/sessions/{id} — single-session snapshot.",
),
Journey(
name="load_conversation_history",
kind="latency",
measure=_measure_load_history,
setup=_setup_target_session,
concurrency_safe=True,
description="GET /v1/sessions/{id}/items — conversation history read.",
),
Journey(
name="search_sessions",
kind="latency",
measure=_measure_search_sessions,
concurrency_safe=True,
description="GET /v1/sessions?search_query= — unindexed LIKE over titles + items.",
),
Journey(
name="fork_session",
kind="latency",
measure=_measure_fork_session,
setup=_setup_fork_session,
teardown=_teardown_fork_session,
concurrency_safe=True,
description="POST /v1/sessions/{id}/fork — session fork (deep-copy); DELETE untimed.",
),
Journey(
name="add_comment",
kind="latency",
measure=_measure_add_comment,
setup=_setup_target_session,
concurrency_safe=True,
description="POST /v1/sessions/{id}/comments — create a review comment.",
),
# Runner (full-turn) journeys — with_runner=True, openai-agents, mock LLM.
Journey(
name="session_cold_start",
kind="latency",
measure=_measure_session_cold_start,
setup=_setup_turn_agent,
needs_runner=True,
max_iterations=_RUNNER_MAX_ITERATIONS,
description="Create+bind a fresh session and drive its first turn to idle.",
),
Journey(
name="warm_turn",
kind="latency",
measure=_measure_warm_turn,
setup=_setup_warm_session,
needs_runner=True,
max_iterations=_RUNNER_MAX_ITERATIONS,
description="Drive a turn on an already-warm session (steady-state overhead).",
),
Journey(
name="time_to_first_token",
kind="latency",
measure=_measure_time_to_first_token,
setup=_setup_streaming_session,
needs_runner=True,
max_iterations=_RUNNER_MAX_ITERATIONS,
description="Post a turn; time to the first streamed output_text delta.",
),
Journey(
name="interrupt",
kind="latency",
measure=_measure_interrupt,
setup=_setup_interrupt_session,
needs_runner=True,
max_iterations=_RUNNER_MAX_ITERATIONS,
description="Interrupt a running (gated) turn; time to cancellation.",
),
Journey(
name="read_runner_file",
kind="latency",
measure=_measure_read_runner_file,
setup=_setup_runner_file_session,
needs_runner=True,
max_iterations=_RUNNER_FS_MAX_ITERATIONS,
description="GET .../environments/default/filesystem/{path} — runner file read proxy.",
),
)
}
def resolve_journeys(names: list[str] | None) -> list[Journey]:
"""Resolve requested journey *names* (or all when ``None``/empty).
:raises KeyError: If a requested name isn't registered.
"""
if not names:
return list(ALL_JOURNEYS.values())
resolved = []
for name in names:
if name not in ALL_JOURNEYS:
raise KeyError(f"unknown journey {name!r}; known: {', '.join(ALL_JOURNEYS)}")
resolved.append(ALL_JOURNEYS[name])
return resolved
+222
View File
@@ -0,0 +1,222 @@
"""Latency/throughput measurement primitives.
Pure and I/O-free: a :class:`RunResult` accumulates per-operation latencies
and failures for one timed run, :func:`aggregate` folds several runs into the
``runs`` + ``summary`` shape the workspace ETL flattens, and
:func:`check_thresholds` gates a run in CI. Adapted from MLflow's
``dev/benchmarks/gateway/benchmark.py``.
"""
from __future__ import annotations
import math
import statistics
from dataclasses import dataclass, field
from rich.console import Console
from rich.table import Table
console = Console()
@dataclass
class RunResult:
"""Latencies and failures collected during one timed run.
:param latencies_ms: Per-operation wall-clock latency in milliseconds,
one entry per successful operation.
:param failures: Failure reason (e.g. ``"HTTP 500"`` / an exception
class name) mapped to how many times it occurred.
:param wall_time: Total elapsed seconds for the run, used for throughput.
"""
latencies_ms: list[float] = field(default_factory=list)
failures: dict[str, int] = field(default_factory=dict)
wall_time: float = 0.0
@property
def n_success(self) -> int:
"""Number of operations that completed without error."""
return len(self.latencies_ms)
@property
def n_failures(self) -> int:
"""Total failed operations across all reasons."""
return sum(self.failures.values())
@property
def throughput(self) -> float:
"""Successful operations per second over the run's wall time."""
return self.n_success / self.wall_time if self.wall_time > 0 else 0.0
def record_failure(self, reason: str) -> None:
"""Increment the count for one failure *reason*."""
self.failures[reason] = self.failures.get(reason, 0) + 1
def percentile(self, p: float) -> float:
"""Return the *p*-th percentile latency in ms (ceil-index method).
:param p: Percentile in ``[0, 100]``, e.g. ``99`` for p99.
:returns: The latency at that percentile, or ``0.0`` when no
successful operation was recorded.
"""
if not self.latencies_ms:
return 0.0
ordered = sorted(self.latencies_ms)
idx = max(0, math.ceil(p / 100 * len(ordered)) - 1)
return ordered[idx]
def mean_ms(self) -> float:
"""Mean latency in ms, or ``0.0`` when no operation succeeded."""
return statistics.mean(self.latencies_ms) if self.latencies_ms else 0.0
def max_ms(self) -> float:
"""Maximum latency in ms, or ``0.0`` when no operation succeeded."""
return max(self.latencies_ms) if self.latencies_ms else 0.0
def _run_to_dict(result: RunResult) -> dict[str, object]:
"""Flatten one :class:`RunResult` into a JSON-serializable per-run row."""
return {
"n_success": result.n_success,
"n_failures": result.n_failures,
"failures": dict(result.failures),
"wall_time_s": result.wall_time,
"mean_ms": result.mean_ms(),
"p50_ms": result.percentile(50),
"p95_ms": result.percentile(95),
"p99_ms": result.percentile(99),
"max_ms": result.max_ms(),
"rps": result.throughput,
}
def aggregate(results: list[RunResult]) -> dict[str, object]:
"""Fold per-run results into ``{"runs": [...], "summary": {...}}``.
The ``summary`` averages each metric across runs. Its keys mirror
MLflow's gateway benchmark (``avg_mean_ms`` / ``avg_p50_ms`` /
``avg_p99_ms`` / ``avg_rps``) plus ``avg_p95_ms``, so the workspace ETL
that flattens ``summary`` works unchanged.
:param results: One :class:`RunResult` per timed run (warmup excluded).
:returns: A dict with a per-run ``runs`` list and an averaged
``summary`` (empty ``summary`` when *results* is empty).
"""
runs = [_run_to_dict(r) for r in results]
if not results:
return {"runs": runs, "summary": {}}
summary = {
"avg_mean_ms": statistics.mean(r.mean_ms() for r in results),
"avg_p50_ms": statistics.mean(r.percentile(50) for r in results),
"avg_p95_ms": statistics.mean(r.percentile(95) for r in results),
"avg_p99_ms": statistics.mean(r.percentile(99) for r in results),
"avg_rps": statistics.mean(r.throughput for r in results),
}
return {"runs": runs, "summary": summary}
def check_thresholds(
results: list[RunResult],
*,
min_rps: float | None = None,
max_p50_ms: float | None = None,
max_p99_ms: float | None = None,
) -> bool:
"""Check averaged results against optional CI thresholds.
:param results: Timed runs for one journey.
:param min_rps: Fail if average throughput is below this (req/s).
:param max_p50_ms: Fail if average p50 latency exceeds this (ms).
:param max_p99_ms: Fail if average p99 latency exceeds this (ms).
:returns: ``True`` when every supplied threshold passes (vacuously
true when none are supplied or *results* is empty).
"""
if not results:
return True
avg_rps = statistics.mean(r.throughput for r in results)
avg_p50 = statistics.mean(r.percentile(50) for r in results)
avg_p99 = statistics.mean(r.percentile(99) for r in results)
passed = True
if min_rps is not None and avg_rps < min_rps:
console.print(
f" [red]THRESHOLD FAILED:[/red] avg throughput {avg_rps:.0f} req/s"
f" < minimum {min_rps:.0f} req/s"
)
passed = False
if max_p50_ms is not None and avg_p50 > max_p50_ms:
console.print(
f" [red]THRESHOLD FAILED:[/red] avg P50 {avg_p50:.1f} ms"
f" > maximum {max_p50_ms:.1f} ms"
)
passed = False
if max_p99_ms is not None and avg_p99 > max_p99_ms:
console.print(
f" [red]THRESHOLD FAILED:[/red] avg P99 {avg_p99:.1f} ms"
f" > maximum {max_p99_ms:.1f} ms"
)
passed = False
return passed
def print_results(journey_name: str, results: list[RunResult]) -> None:
"""Render per-run and averaged metrics for one journey as a rich table.
:param journey_name: Journey label used as the table title.
:param results: Timed runs to display.
"""
table = Table(
title=journey_name,
show_header=True,
header_style="bold cyan",
box=None,
padding=(0, 2),
title_justify="left",
)
table.add_column("Run", style="dim", width=5)
table.add_column("Mean ms", justify="right")
table.add_column("P50 ms", justify="right")
table.add_column("P95 ms", justify="right")
table.add_column("P99 ms", justify="right")
table.add_column("Max ms", justify="right")
table.add_column("Req/s", justify="right")
table.add_column("Failures", justify="right")
for i, r in enumerate(results):
fail_str = f"[red]{r.n_failures}[/red]" if r.n_failures else "0"
table.add_row(
str(i + 1),
f"{r.mean_ms():.1f}",
f"{r.percentile(50):.1f}",
f"{r.percentile(95):.1f}",
f"{r.percentile(99):.1f}",
f"{r.max_ms():.1f}",
f"{r.throughput:.0f}",
fail_str,
)
if len(results) > 1:
table.add_section()
table.add_row(
"[bold]avg[/bold]",
f"[bold]{statistics.mean(r.mean_ms() for r in results):.1f}[/bold]",
f"[bold]{statistics.mean(r.percentile(50) for r in results):.1f}[/bold]",
f"[bold]{statistics.mean(r.percentile(95) for r in results):.1f}[/bold]",
f"[bold]{statistics.mean(r.percentile(99) for r in results):.1f}[/bold]",
f"[bold]{statistics.mean(r.max_ms() for r in results):.1f}[/bold]",
f"[bold]{statistics.mean(r.throughput for r in results):.0f}[/bold]",
"",
)
console.print()
console.print(table)
combined: dict[str, int] = {}
for r in results:
for reason, count in r.failures.items():
combined[reason] = combined.get(reason, 0) + count
if combined:
console.print(" [red]Failure breakdown:[/red]")
for reason, count in sorted(combined.items(), key=lambda kv: -kv[1]):
console.print(f" {reason}: {count}")
+266
View File
@@ -0,0 +1,266 @@
"""Omnigent user-journey benchmark runner.
Boots a real ``omnigent server`` against a SQLite DB (no runner, no LLM),
drives the selected HTTP journeys under load, prints per-journey latency /
throughput tables, and writes a versioned JSON report. Exits non-zero if any
supplied threshold is breached.
Runs in the project venv — it imports ``omnigent`` and ``tests._helpers`` and
spawns the real server, so it is NOT a standalone PEP 723 script. Invoke with
``--no-sync`` so ``uv`` uses the existing environment instead of rebuilding the
project (which triggers a web-UI build that fails in a worktree)::
uv run --no-sync dev/benchmarks/omnigent/run.py
uv run --no-sync dev/benchmarks/omnigent/run.py --journeys list_sessions,get_session
uv run --no-sync dev/benchmarks/omnigent/run.py --requests 500 --concurrency 25 --runs 3
uv run --no-sync dev/benchmarks/omnigent/run.py --output bench.json --max-p50-ms 25
The JSON is the contract consumed by the workspace Databricks ETL notebook —
see ``README.md``.
"""
from __future__ import annotations
import argparse
import asyncio
import datetime
import json
import sys
from pathlib import Path
# Allow ``uv run <path>`` (no package context) to import the sibling modules.
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from dev.benchmarks.omnigent.environment import BenchEnvironment
from dev.benchmarks.omnigent.journeys import (
ALL_JOURNEYS,
Journey,
resolve_journeys,
run_latency,
run_throughput,
)
from dev.benchmarks.omnigent.measure import (
RunResult,
aggregate,
check_thresholds,
console,
print_results,
)
from dev.benchmarks.omnigent.schema import build_report
# Harness label stamped in the report: HTTP/DB journeys drive no agent turn;
# runner journeys drive turns through the in-process openai-agents SDK harness.
_HTTP_HARNESS = "http-only"
_RUNNER_HARNESS = "openai-agents"
def _backend_of(database_uri: str | None) -> str:
"""Classify the DB URI into a coarse backend label for the report.
``None`` is the harness's throwaway SQLite temp file. Otherwise key off the
URI scheme so the report (and the workspace dashboard) can group by backend.
"""
if database_uri is None or database_uri.startswith("sqlite"):
return "sqlite"
if database_uri.startswith("postgres"):
return "postgres"
return "other"
def _effective_iterations(journey: Journey, requested: int) -> int:
"""Clamp *requested* iterations down to the journey's ``max_iterations``.
Full-turn journeys cost ~1s+ per op and cap themselves so a large
``--iterations`` (tuned for the millisecond HTTP journeys) doesn't overrun
the CI time budget. The cap only ever lowers the count, never raises it.
"""
if journey.max_iterations is not None:
return min(requested, journey.max_iterations)
return requested
async def _run_journey(
journey: Journey, env: BenchEnvironment, args: argparse.Namespace
) -> tuple[str, list[RunResult]]:
"""Run one journey's timed runs, returning its report kind + per-run results.
A journey runs as throughput when ``--concurrency > 1`` and it is
concurrency-safe; otherwise as sequential latency.
"""
as_throughput = args.concurrency > 1 and journey.concurrency_safe
iterations = _effective_iterations(journey, args.iterations)
results: list[RunResult] = []
for _ in range(args.runs):
if as_throughput:
results.append(
await run_throughput(
journey,
env,
requests=args.requests,
concurrency=args.concurrency,
warmup=args.warmup,
)
)
else:
results.append(
await run_latency(journey, env, iterations=iterations, warmup=args.warmup)
)
return ("throughput" if as_throughput else "latency"), results
async def run_benchmark(args: argparse.Namespace) -> tuple[dict[str, object], bool]:
"""Run all selected journeys and build the report.
:returns: ``(report, passed)`` where *passed* is ``False`` if any journey
breached a supplied threshold.
"""
journeys = resolve_journeys(args.journeys)
journey_results: dict[str, dict[str, object]] = {}
passed = True
backend = _backend_of(args.database_uri)
# Any full-turn journey needs the runner + mock LLM. A full env is a
# superset — HTTP journeys still run against it — so a mixed selection just
# boots with_runner=True. The harness label reflects what drove the turns.
with_runner = any(j.needs_runner for j in journeys)
harness = _RUNNER_HARNESS if with_runner else _HTTP_HARNESS
async with BenchEnvironment(with_runner=with_runner, database_uri=args.database_uri) as env:
for journey in journeys:
console.print(f"\n[bold]Benchmarking[/bold] {journey.name} [dim]({backend})[/dim]")
kind, results = await _run_journey(journey, env, args)
print_results(journey.name, results)
block = aggregate(results)
block["kind"] = kind
block["backend"] = backend
journey_results[journey.name] = block
if not check_thresholds(
results,
min_rps=args.min_rps,
max_p50_ms=args.max_p50_ms,
max_p99_ms=args.max_p99_ms,
):
passed = False
config = {
"iterations": args.iterations,
"requests": args.requests,
"concurrency": args.concurrency,
"runs": args.runs,
"warmup": args.warmup,
"with_runner": with_runner,
"backend": backend,
}
generated_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
report = build_report(
journey_results,
generated_at=generated_at,
config=config,
harness=harness,
)
return report, passed
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="omnigent-benchmark",
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--journeys",
type=lambda s: [p.strip() for p in s.split(",") if p.strip()],
default=None,
metavar="A,B,C",
help=f"Comma-separated journeys to run. Default: all ({', '.join(ALL_JOURNEYS)}).",
)
parser.add_argument(
"--database-uri",
default=None,
metavar="URI",
help="DB the server boots against — a pre-seeded SQLite file or a "
"postgresql+psycopg://… instance (see seed.py). Default: a fresh "
"throwaway SQLite DB (empty — best-case numbers). The report's "
"`backend` field is derived from this.",
)
parser.add_argument(
"--iterations",
type=int,
default=100,
metavar="N",
help="Sequential operations per latency run (default: 100).",
)
parser.add_argument(
"--requests",
type=int,
default=500,
metavar="N",
help="Total operations per throughput run — used when --concurrency>1 (default: 500).",
)
parser.add_argument(
"--concurrency",
type=int,
default=1,
metavar="N",
help="Max in-flight operations. >1 runs concurrency-safe journeys as "
"throughput (default: 1 = sequential latency).",
)
parser.add_argument(
"--runs",
type=int,
default=3,
metavar="N",
help="Timed runs per journey; results are per-run and averaged (default: 3).",
)
parser.add_argument(
"--warmup",
type=int,
default=10,
metavar="N",
help="Warmup operations discarded before each run (default: 10).",
)
parser.add_argument(
"--output",
type=Path,
default=None,
metavar="FILE",
help="Write the JSON report to FILE (for CI artifact upload).",
)
parser.add_argument(
"--min-rps",
type=float,
default=None,
metavar="N",
help="Exit 1 if any journey's avg throughput falls below N req/s.",
)
parser.add_argument(
"--max-p50-ms",
type=float,
default=None,
metavar="N",
help="Exit 1 if any journey's avg P50 latency exceeds N ms.",
)
parser.add_argument(
"--max-p99-ms",
type=float,
default=None,
metavar="N",
help="Exit 1 if any journey's avg P99 latency exceeds N ms.",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv if argv is not None else sys.argv[1:])
report, passed = asyncio.run(run_benchmark(args))
if args.output is not None:
args.output.write_text(json.dumps(report, indent=2))
console.print(f"\n Results written to [cyan]{args.output}[/cyan]")
if not passed:
console.print("\n[red]One or more thresholds failed.[/red]")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+268
View File
@@ -0,0 +1,268 @@
{
"schema_version": 1,
"generated_at": "2026-07-08T18:30:00+00:00",
"git_sha": "0000000000000000000000000000000000000000",
"git_branch": "main",
"host": {
"platform": "macOS-15.5-arm64-arm-64bit",
"python": "3.12.8",
"cpu_count": 12
},
"harness": "http-only",
"config": {
"iterations": 100,
"requests": 500,
"concurrency": 1,
"runs": 3,
"warmup": 10,
"with_runner": false,
"backend": "sqlite"
},
"journeys": {
"list_sessions": {
"runs": [
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.6587757079978473,
"mean_ms": 6.586994149838574,
"p50_ms": 6.261250004172325,
"p95_ms": 7.65325000975281,
"p99_ms": 7.9127089702524245,
"max_ms": 38.27937500318512,
"rps": 151.79673261468648
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.6238234580378048,
"mean_ms": 6.237602901528589,
"p50_ms": 6.126708001829684,
"p95_ms": 7.152916979975998,
"p99_ms": 7.425624993629754,
"max_ms": 7.667875033803284,
"rps": 160.30176280087855
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.5675292499945499,
"mean_ms": 5.674769564066082,
"p50_ms": 5.528832960408181,
"p95_ms": 6.237249996047467,
"p99_ms": 7.393250009045005,
"max_ms": 11.83562504593283,
"rps": 176.20237194992208
}
],
"summary": {
"avg_mean_ms": 6.166455538477749,
"avg_p50_ms": 5.972263655470063,
"avg_p95_ms": 7.014472328592092,
"avg_p99_ms": 7.577194657642394,
"avg_rps": 162.7669557884957
},
"kind": "latency",
"backend": "sqlite"
},
"create_session": {
"runs": [
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 2.4451411250047386,
"mean_ms": 24.450668342760764,
"p50_ms": 24.028874991927296,
"p95_ms": 27.25041698431596,
"p99_ms": 29.374166973866522,
"max_ms": 29.56758299842477,
"rps": 40.89743490769115
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 2.498600291030016,
"mean_ms": 24.985182073432952,
"p50_ms": 24.459875014144927,
"p95_ms": 28.391665953677148,
"p99_ms": 29.0600000298582,
"max_ms": 34.39550002804026,
"rps": 40.02240788932922
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 2.455423459003214,
"mean_ms": 24.553418274153955,
"p50_ms": 24.073333013802767,
"p95_ms": 27.43383398046717,
"p99_ms": 29.375000041909516,
"max_ms": 29.430416005197912,
"rps": 40.72617276394162
}
],
"summary": {
"avg_mean_ms": 24.663089563449223,
"avg_p50_ms": 24.187361006624997,
"avg_p95_ms": 27.691972306153428,
"avg_p99_ms": 29.269722348544747,
"avg_rps": 40.548671853654
},
"kind": "latency",
"backend": "sqlite"
},
"get_session": {
"runs": [
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.5546917500323616,
"mean_ms": 5.5460037814918905,
"p50_ms": 5.360124981962144,
"p95_ms": 6.925499998033047,
"p99_ms": 7.144333969336003,
"max_ms": 7.331291970331222,
"rps": 180.28030882767922
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.49645508296089247,
"mean_ms": 4.963922067545354,
"p50_ms": 4.782959003932774,
"p95_ms": 5.978292028885335,
"p99_ms": 6.7617910099215806,
"max_ms": 6.881375040393323,
"rps": 201.42809174919327
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.460644083970692,
"mean_ms": 4.605880451854318,
"p50_ms": 4.526541975792497,
"p95_ms": 5.18629199359566,
"p99_ms": 5.445250018965453,
"max_ms": 5.790999974124134,
"rps": 217.08734244020465
}
],
"summary": {
"avg_mean_ms": 5.0386021002971875,
"avg_p50_ms": 4.889875320562472,
"avg_p95_ms": 6.030028006838013,
"avg_p99_ms": 6.450458332741012,
"avg_rps": 199.59858100569238
},
"kind": "latency",
"backend": "sqlite"
},
"load_conversation_history": {
"runs": [
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.20811437495285645,
"mean_ms": 2.080691678565927,
"p50_ms": 2.037000027485192,
"p95_ms": 2.5742079596966505,
"p99_ms": 2.768124977592379,
"max_ms": 2.784749958664179,
"rps": 480.50501087516284
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.19513549999101087,
"mean_ms": 1.9509158097207546,
"p50_ms": 1.9018329912796617,
"p95_ms": 2.284207963384688,
"p99_ms": 2.4481670116074383,
"max_ms": 2.5021659675985575,
"rps": 512.4644157757384
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.19278304203180596,
"mean_ms": 1.9274150469573215,
"p50_ms": 1.8819589749909937,
"p95_ms": 2.2150420118123293,
"p99_ms": 2.2878749878145754,
"max_ms": 2.316958038136363,
"rps": 518.7178236532945
}
],
"summary": {
"avg_mean_ms": 1.9863408450813342,
"avg_p50_ms": 1.9402639979186158,
"avg_p95_ms": 2.3578193116312227,
"avg_p99_ms": 2.5013889923381307,
"avg_rps": 503.8957501013986
},
"kind": "latency",
"backend": "sqlite"
},
"search_sessions": {
"runs": [
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 8.150427000015043,
"mean_ms": 81.50338126753923,
"p50_ms": 80.11816703947261,
"p95_ms": 90.9090840141289,
"p99_ms": 94.67683301772922,
"max_ms": 96.44366696011275,
"rps": 12.26929582950874
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 8.152122708968818,
"mean_ms": 81.5203430026304,
"p50_ms": 79.57212498877198,
"p95_ms": 95.20591603359208,
"p99_ms": 98.80137501750141,
"max_ms": 99.93629204109311,
"rps": 12.266743714490682
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 8.053999124967959,
"mean_ms": 80.53909589187242,
"p50_ms": 79.52124997973442,
"p95_ms": 91.39445802429691,
"p99_ms": 93.2748339837417,
"max_ms": 94.79766699951142,
"rps": 12.416192061654566
}
],
"summary": {
"avg_mean_ms": 81.18760672068068,
"avg_p50_ms": 79.73718066932634,
"avg_p95_ms": 92.50315269067262,
"avg_p99_ms": 95.58434733965744,
"avg_rps": 12.317410535217997
},
"kind": "latency",
"backend": "sqlite"
}
}
}
+89
View File
@@ -0,0 +1,89 @@
"""Benchmark report schema + metadata capture.
:func:`build_report` assembles the single JSON document the harness writes.
Its per-journey ``summary`` + ``runs`` shape mirrors MLflow's gateway
benchmark so the workspace ETL notebook flattens it unchanged — keyed by
journey (and ``harness``) instead of ``backend``. Bump :data:`SCHEMA_VERSION`
whenever the document's shape changes so the ETL can branch on it.
"""
from __future__ import annotations
import platform
import subprocess
# Incremented on any breaking change to the report document shape below.
SCHEMA_VERSION = 1
def _git(*args: str) -> str:
"""Run ``git *args`` at the repo root, returning stripped stdout or ``""``.
Never raises: a missing git, detached checkout, or non-zero exit all
surface as an empty string so a benchmark run outside a clean checkout
still produces a valid report.
"""
try:
out = subprocess.run(
["git", *args],
capture_output=True,
text=True,
timeout=10,
check=False,
)
except (OSError, subprocess.SubprocessError):
return ""
return out.stdout.strip() if out.returncode == 0 else ""
def git_sha() -> str:
"""Return the current commit SHA, or ``""`` when unavailable."""
return _git("rev-parse", "HEAD")
def git_branch() -> str:
"""Return the current branch name, or ``""`` when detached/unavailable."""
return _git("rev-parse", "--abbrev-ref", "HEAD")
def host_info() -> dict[str, object]:
"""Capture coarse host facts for cross-machine result comparison."""
import os
return {
"platform": platform.platform(),
"python": platform.python_version(),
"cpu_count": os.cpu_count(),
}
def build_report(
journey_results: dict[str, dict[str, object]],
*,
generated_at: str,
config: dict[str, object],
harness: str,
) -> dict[str, object]:
"""Assemble the full benchmark report document.
:param journey_results: Per-journey ``{"kind", "runs", "summary"}``
blocks (each ``runs``/``summary`` produced by
:func:`measure.aggregate`), keyed by journey name.
:param generated_at: ISO-8601 timestamp stamped by the caller (kept out
of this pure function so it stays deterministic under test).
:param config: The run's knobs (iterations, requests, concurrency, runs,
mock_llm) for provenance.
:param harness: Harness driving full-turn journeys, e.g.
``"openai-agents"``.
:returns: The JSON-serializable report document.
"""
return {
"schema_version": SCHEMA_VERSION,
"generated_at": generated_at,
"git_sha": git_sha(),
"git_branch": git_branch(),
"host": host_info(),
"harness": harness,
"config": config,
"journeys": journey_results,
}
+228
View File
@@ -0,0 +1,228 @@
"""Deterministic corpus seeder for the performance benchmark.
The v1 harness booted an empty DB, so the read journeys measured a best-case
near-empty table. This seeds a sizeable, realistic corpus directly through the
store API (no HTTP, no runner) so ``list_sessions`` / ``get_session`` /
``load_conversation_history`` read a production-shaped volume.
Writes to the same DB URI the server later boots against; startup migrations
are an idempotent no-op on an at-head DB. The seed is deterministic (fixed RNG,
fixed counts) so the same config always yields the same corpus — which is what
makes "seed once, reuse" sound. The reuse marker records the Alembic head read
at seed time, so a corpus from an older schema is auto-reseeded (no manual
revision bookkeeping).
Listable-corpus recipe, per session (the permission grant is the gotcha — the
loopback server resolves every request to user ``"local"`` and
``list_sessions`` filters by it):
1. ``create_session_with_agent`` — conversation + session-scoped agent row.
2. ``permission_store.grant("local", sid, LEVEL_OWNER)`` — makes it listable.
3. one batched ``append(sid, items)`` — user-role message items.
Run standalone::
uv run --no-sync dev/benchmarks/omnigent/seed.py \
--database-uri sqlite:///tmp/bench.db --sessions 5000 --items-per-session 50
"""
from __future__ import annotations
import argparse
import random
import sys
from pathlib import Path
# Allow ``uv run <path>`` (no package context) to import omnigent + siblings.
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from omnigent.db.utils import _get_head_db_revision, generate_agent_id
from omnigent.entities import MessageData, NewConversationItem
from omnigent.server.auth import LEVEL_OWNER, RESERVED_USER_LOCAL
from omnigent.stores.conversation_store.sqlalchemy_store import SqlAlchemyConversationStore
from omnigent.stores.permission_store.sqlalchemy_store import SqlAlchemyPermissionStore
# Label key stamped on the first seeded session recording the corpus config, so
# a later run can detect an existing (and matching) seed and skip re-seeding.
_SEED_META_LABEL = "omni_bench_seed"
# Fixed identifiers so the corpus is byte-stable across runs at a given config.
_AGENT_NAME = "bench-agent"
_DEFAULT_SESSIONS = 5000
_DEFAULT_ITEMS = 50
_DEFAULT_RNG_SEED = 1234
# A pool of realistic-ish message fragments; the RNG assembles item text from
# these so search_text has lexical variety without external data.
_FRAGMENTS = (
"investigate the failing migration",
"the runner keeps disconnecting under load",
"add pagination to the sessions endpoint",
"why does the policy classifier time out",
"refactor the conversation store append path",
"benchmark the list endpoints against postgres",
"the web UI drops the last streamed token",
"trace the tunnel handshake for this runner id",
"summarize the changes in this pull request",
"reproduce the elicitation race on reconnect",
)
def _meta_value(sessions: int, items_per_session: int, rng_seed: int, head: str) -> str:
"""Serialize the corpus config into the seed-marker label value.
Includes the Alembic *head* read at seed time, so a corpus seeded under an
older schema auto-mismatches the current head and is reseeded — no
hand-maintained revision constant.
"""
return f"sessions={sessions};items={items_per_session};rng={rng_seed};rev={head}"
def _existing_seed_meta(conv: SqlAlchemyConversationStore) -> str | None:
"""Return the seed-marker label value if a bench corpus already exists.
Looks up the most recent ``bench-agent`` session and reads its
``omni_bench_seed`` label. ``None`` means no (recognizable) seed present.
"""
listing = conv.list_conversations(limit=1, agent_name=_AGENT_NAME)
if not listing.data:
return None
marked = conv.get_conversation(listing.data[0].id)
return marked.labels.get(_SEED_META_LABEL) if marked is not None else None
def _make_items(rng: random.Random, count: int) -> list[NewConversationItem]:
"""Build *count* deterministic user-role message items.
User-role only: assistant messages require an ``agent`` field the store
only assigns after a real turn, and the seeded read path is role-agnostic.
"""
items: list[NewConversationItem] = []
for i in range(count):
text = f"{rng.choice(_FRAGMENTS)} (item {i})"
items.append(
NewConversationItem(
type="message",
response_id=f"resp_seed_{i}",
data=MessageData(role="user", content=[{"type": "input_text", "text": text}]),
)
)
return items
def seed(
db_uri: str,
*,
sessions: int = _DEFAULT_SESSIONS,
items_per_session: int = _DEFAULT_ITEMS,
rng_seed: int = _DEFAULT_RNG_SEED,
reseed: bool = False,
) -> int:
"""Seed *sessions* sessions × *items_per_session* items into *db_uri*.
Idempotent: if a matching seed already exists (same config + schema
revision) it is left untouched unless *reseed* is set. Constructing the
store runs migrations to head on first init, so *db_uri* need not
pre-exist.
:param db_uri: SQLAlchemy URI the server will also boot against, e.g.
``"sqlite:///abs/bench.db"`` or ``"postgresql+psycopg://…"``.
:param sessions: Number of listable sessions to create.
:param items_per_session: Conversation items appended to each session.
:param rng_seed: Seed for the deterministic text RNG.
:param reseed: Seed even when a matching corpus is already present.
:returns: The number of sessions created (0 when a matching seed is reused).
"""
conv = SqlAlchemyConversationStore(db_uri)
perms = SqlAlchemyPermissionStore(db_uri)
# Read the current schema head at runtime (no DB contacted) and fold it into
# the reuse marker, so a corpus from an older schema is auto-reseeded.
head = _get_head_db_revision("sqlite:///:memory:")
want = _meta_value(sessions, items_per_session, rng_seed, head)
if not reseed:
existing = _existing_seed_meta(conv)
if existing == want:
print(f"seed: matching corpus already present ({want}); skipping")
return 0
if existing is not None:
print(f"seed: existing corpus differs ({existing!r} != {want!r}); pass --reseed")
return 0
perms.ensure_user(RESERVED_USER_LOCAL)
rng = random.Random(rng_seed)
last_sid = ""
for s in range(sessions):
created = conv.create_session_with_agent(
agent_id=generate_agent_id(),
agent_name=_AGENT_NAME,
agent_bundle_location="bench/seed", # never validated on the read path
agent_description=None,
title=f"bench session {s}: {rng.choice(_FRAGMENTS)}",
)
sid = created.conversation.id
last_sid = sid
perms.grant(RESERVED_USER_LOCAL, sid, LEVEL_OWNER)
if items_per_session:
conv.append(sid, _make_items(rng, items_per_session))
if sessions >= 100 and s % (sessions // 10) == 0 and s:
print(f"seed: {s}/{sessions} sessions")
# Stamp the corpus config on the LAST (newest) session — that's the one
# ``_existing_seed_meta``'s default desc listing returns, so the reuse
# check finds it regardless of corpus size.
if last_sid:
conv.set_labels(last_sid, {_SEED_META_LABEL: want})
print(f"seed: created {sessions} sessions × {items_per_session} items ({want})")
return sessions
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="omnigent-benchmark-seed",
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--database-uri",
metavar="URI",
help="DB to seed. Required unless --print-head.",
)
parser.add_argument("--sessions", type=int, default=_DEFAULT_SESSIONS, metavar="N")
parser.add_argument("--items-per-session", type=int, default=_DEFAULT_ITEMS, metavar="N")
parser.add_argument("--rng-seed", type=int, default=_DEFAULT_RNG_SEED, metavar="N")
parser.add_argument(
"--reseed",
action="store_true",
help="Seed even if a matching corpus is already present.",
)
parser.add_argument(
"--print-head",
action="store_true",
help="Print the repo's Alembic head revision and exit (drift-check helper).",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv if argv is not None else sys.argv[1:])
if args.print_head:
print(_get_head_db_revision("sqlite:///:memory:"))
return 0
if not args.database_uri:
print("seed: --database-uri is required (unless --print-head)", file=sys.stderr)
return 2
seed(
args.database_uri,
sessions=args.sessions,
items_per_session=args.items_per_session,
rng_seed=args.rng_seed,
reseed=args.reseed,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1169
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "omnidev"
version = "0.1.0"
edition = "2021"
description = "Per-repo dev pod supervisor TUI for the Omnigent repo"
publish = false
[[bin]]
name = "omnidev"
path = "src/main.rs"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
crossterm = "0.28"
ratatui = "0.29"
ansi-to-tui = "7"
notify = "8"
notify-debouncer-full = "0.5"
libc = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
tokio = { version = "1", features = [
"rt-multi-thread",
"macros",
"process",
"io-util",
"net",
"time",
"sync",
"signal",
] }
if-addrs = "0.15"
+177
View File
@@ -0,0 +1,177 @@
# omnidev
Dev tooling for Omnigent, in one binary with two independent capabilities:
1. A per-repo dev **pod supervisor** (bare `omnidev`) — the default.
2. **Install management** (`omnidev install`/`update`/`check`) — install and
keep a git-based omnigent up to date. See
[Managing your omnigent install](#managing-your-omnigent-install). These
subcommands need no checkout and run anywhere.
## Pod supervisor
A per-repo dev **pod** supervisor, as a single long-running terminal UI. It
replaces the three-terminal local dev flow (`omnigent server`, `omnigent host`,
`npm run dev`) with one process that:
- runs each checkout in an **isolated pod** — its own state dir, database,
artifacts, logs, and auto-allocated ports — so multiple worktrees never
collide;
- **supervises** the backend server, the host daemon, and the Vite frontend,
restarting any that crash (with backoff);
- **reloads the backend** (server → host) when you edit `omnigent/**/*.py`;
the frontend self-reloads through Vite HMR;
- gives you **scrollable per-process log panes** plus a combined view.
## Build & run
Requires the repo's usual dev prerequisites (`uv` for Python, `npm` for the
web UI) plus a Rust toolchain.
```bash
cd dev/omnidev
cargo run # launches the TUI for the surrounding checkout
```
Run it from anywhere inside the checkout — it walks up to the repo root
(the `.jj`/`.git` marker) and requires `omnigent/` and
`web/` to be present. Build a release binary with `cargo build --release`
(lands at `target/release/omnidev`).
## What it starts
| Process | Command | Notes |
|---|---|---|
| server | `uv run omnigent server --host 127.0.0.1 --port <p> --database-uri … --artifact-location …` | Waited on via `GET /health`. |
| host | `uv run omnigent host --server http://127.0.0.1:<p>` | Started once the server is healthy. |
| vite | `npm run dev -- --host <host> --port <p> --strictPort` (cwd `web/`) | `OMNIGENT_URL` points its proxy at the pod's server. |
Before Vite starts (and on a manual Vite restart), omnidev runs `npm install`
in `web/` when needed — `node_modules/` is missing, or `package.json` /
`package-lock.json` is newer than it — so a fresh checkout or a new dependency
doesn't make Vite fail its dependency scan. Output streams into the `vite` pane.
Open the UI at the `ui` URL shown in the header (the Vite dev server).
## Isolation
Only Omnigent's own state is isolated per pod — enough that concurrent pods
never share a database, server pidfile, or `config.yaml` — via
`OMNIGENT_DATA_DIR`, `OMNIGENT_DATABASE_URI`, `OMNIGENT_URL`, and
`OMNIGENT_CONFIG_HOME`. Everything else (your real `HOME`, credentials, and
uv/npm caches) is inherited, because the agents Omnigent runs need it. This is
deliberately lighter than the hermetic `scripts/backend-smoke.sh` sandbox,
which repoints `HOME`/`XDG_*` to touch nothing real.
Each pod gets its own `config.yaml` under `<pod>/config/`, pointed to by
`OMNIGENT_CONFIG_HOME`. On first create it's **seeded** from your real
`~/.omnigent/config.yaml` (if present) so the pod works out of the box — it
keeps your providers — after which the two are independent: server-config edits
inside a pod (via the UI or `omnigent config`) don't touch your real config.
`--clean` wipes the pod dir, so the next run re-seeds from your real config.
The pod dir defaults to
`${XDG_CACHE_HOME:-~/.cache}/omnidev/<repo-name>-<hash>/`, keyed to the
canonical checkout path. Per-process logs are written through to
`<pod>/logs/{server,host,vite}.log` for inspection outside the TUI.
## Options
```
--server-port <N> Force the backend port (default: probe from 6767)
--vite-port <N> Force the Vite port (default: probe from 5173)
--vite-host <ADDR> Vite bind host (default: 127.0.0.1; use 0.0.0.0 for LAN access)
--trust-lan-origins Trust this machine's LAN origins (for device testing)
--pod-dir <PATH> Use a specific pod dir instead of the per-repo default
--no-vite Backend + host only (no frontend)
--clean Wipe the pod dir before starting
```
`--vite-host 0.0.0.0` exposes the Vite dev server on all interfaces for device
testing. Vite still proxies API traffic to the pod backend through `127.0.0.1`.
### Testing from a phone or tablet
`--vite-host 0.0.0.0` alone lets a device load the UI, but the backend runs in
single-user local mode, where its CSRF/CSWSH guard trusts only loopback
origins. A device loads the UI at `http://<your-lan-ip>:<vite-port>`, so its
browser stamps that non-loopback origin on every request — and the guard then
rejects multipart uploads (403) and refuses the live WebSocket stream.
`--trust-lan-origins` fixes that: omnidev enumerates this machine's LAN IPv4
addresses and trusts the matching `http://<ip>:<vite-port>` origins via the
server's `OMNIGENT_WS_ALLOWED_ORIGINS` allowlist (merged with any value you
already export). It stays exact-match — only those origins are trusted, nothing
is disabled — so it's for dev pods, not deployed servers. The trusted origins
are printed in the combined log at startup.
```bash
omnidev --vite-host 0.0.0.0 --trust-lan-origins
```
This covers IPv4 LAN addresses; mDNS `.local` hostnames and HTTPS origins are
not auto-trusted (add those to `OMNIGENT_WS_ALLOWED_ORIGINS` yourself).
## Keys
| Key | Action |
|---|---|
| `1` / `2` / `3` / `0` | Focus server / host / vite / combined pane |
| `Tab` | Cycle panes |
| `↑` `↓` `PgUp` `PgDn` | Scroll (detaches from tail) |
| `f` | Toggle follow-tail |
| `r` | Restart the focused process (server/host restart as a pair) |
| `R` | Restart the backend (server then host) |
| `c` | Clear the focused pane |
| `q` / `Ctrl-C` | Quit and tear down all processes |
## Managing your omnigent install
For people who *run* omnigent (installed from git via `uv tool install`) rather
than develop it. This wraps the fiddly PEP 508 install syntax and adds a daily
update check — filling a gap, since omnigent's own update notice only works for
PyPI-wheel installs and skips git installs.
These subcommands manage the global tool and work from **any directory** (no
checkout needed).
```
omnidev install # uv tool install omnigent from git (databricks extra, main)
omnidev update # reinstall the latest of the tracked ref/extras
omnidev check # check for an update; prompt to update on a TTY
omnidev refresh # refresh the check cache from the network (usually detached)
omnidev shell-hook # print the daily-check snippet for your shell rc
```
`install` options: `--ref <branch/tag/sha>` (default `main`), `--extra <name>`
(repeatable; defaults to `databricks`), `--no-default-extra` (install with no
extras), `--repo <url>`. The choice is saved to
`${XDG_CONFIG_HOME:-~/.config}/omnidev/install.toml` so `update` reuses it.
Installing from git **builds the web UI from source**, so Node 22+/npm must be
on PATH (the PyPI wheel ships the UI prebuilt; the git install does not).
`omnidev install` fails early with a clear message if `uv` or `npm` is missing.
### Daily update check
Append the hook to your shell rc once to be told, at most once a day, when a
newer `main` commit is available — and be offered to update on the spot:
```bash
omnidev shell-hook >> ~/.zshrc # or ~/.bashrc
```
The snippet itself guards on `command -v omnidev`, so it's a no-op in shells
where omnidev isn't on PATH — nothing to fail. (Appending the snippet is
preferred over `eval "$(omnidev shell-hook)"`: the latter would run omnidev on
every shell startup and print a "command not found" error whenever omnidev is
absent.)
On each interactive shell it runs `omnidev check --quiet`, which reads a cached
result (`${XDG_CACHE_HOME:-~/.cache}/omnidev/omnigent-check.json`) and, when
stale (>24h), refreshes it in a detached background process — so shell startup
never blocks on the network. When a newer commit is available it prints a notice
and, on a terminal, prompts `Update omnigent now? [y/N]`; on yes it runs
`omnidev update` in the foreground. Declining suppresses that same commit until a
newer one lands. Set `OMNIGENT_NO_UPDATE_CHECK` in your environment if you want
to silence omnigent's own separate notice.
+209
View File
@@ -0,0 +1,209 @@
//! Manage the user's git-based omnigent installation via `uv tool install`.
//!
//! None of this needs a local checkout: it drives `uv` and reads the installed
//! tool's metadata, and any git call targets the remote.
use std::path::PathBuf;
use std::process::Command;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::paths;
pub const DEFAULT_REPO: &str = "https://github.com/omnigent-ai/omnigent.git";
pub const DEFAULT_REF: &str = "main";
pub const DEFAULT_EXTRA: &str = "databricks";
const PYTHON_VERSION: &str = "3.12";
/// Durable record of how the user wants omnigent installed. Persisted so
/// `update` reinstalls the same repo/ref/extras without re-specifying them.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallConfig {
pub repo: String,
#[serde(rename = "ref")]
pub git_ref: String,
pub extras: Vec<String>,
}
impl Default for InstallConfig {
fn default() -> Self {
InstallConfig {
repo: DEFAULT_REPO.to_string(),
git_ref: DEFAULT_REF.to_string(),
extras: vec![DEFAULT_EXTRA.to_string()],
}
}
}
impl InstallConfig {
pub fn load() -> Result<Option<InstallConfig>> {
let path = paths::install_config_path()?;
match std::fs::read_to_string(&path) {
Ok(text) => Ok(Some(
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?,
)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
pub fn save(&self) -> Result<()> {
let path = paths::install_config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let text = toml::to_string(self).context("serializing install config")?;
std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
/// The PEP 508 install spec, e.g.
/// `omnigent[databricks] @ git+https://github.com/omnigent-ai/omnigent.git@main`.
/// With no extras it collapses to the bare `git+<repo>@<ref>` URL.
pub fn spec(&self) -> String {
let source = format!("git+{}@{}", self.repo, self.git_ref);
if self.extras.is_empty() {
source
} else {
format!("omnigent[{}] @ {}", self.extras.join(","), source)
}
}
}
/// Fail early with a clear message if the toolchain a git install needs is
/// missing. Installing from git builds the web UI from source (Node/npm),
/// unlike the PyPI wheel which ships it prebuilt.
fn preflight() -> Result<()> {
if which("uv").is_none() {
bail!("`uv` is not on PATH. Install it first: https://docs.astral.sh/uv/");
}
if which("npm").is_none() {
bail!(
"`npm` is not on PATH. Installing omnigent from git builds the web UI \
from source and needs Node 22+/npm. Install Node, then retry."
);
}
Ok(())
}
/// Install omnigent from git per `config`. `reinstall` forces uv past its cache
/// so a moving ref (e.g. `main`) actually re-resolves.
pub fn run_uv_install(config: &InstallConfig, reinstall: bool) -> Result<()> {
preflight()?;
let spec = config.spec();
let mut cmd = Command::new("uv");
cmd.args(["tool", "install", "--force", "--python", PYTHON_VERSION]);
if reinstall {
cmd.arg("--reinstall");
}
cmd.arg(&spec);
eprintln!("omnidev: uv tool install {spec}");
let status = cmd
.status()
.context("running `uv tool install` (is uv installed?)")?;
if !status.success() {
bail!("`uv tool install` failed ({status})");
}
Ok(())
}
/// `install` subcommand: persist intent, install, then record the resolved sha.
pub fn install(config: &InstallConfig) -> Result<()> {
config.save()?;
run_uv_install(config, false)?;
record_installed_sha(config);
println!("omnidev: installed omnigent ({})", config.spec());
Ok(())
}
/// `update` subcommand: reinstall the latest of the persisted ref/extras. Falls
/// back to defaults when no config has been written yet.
pub fn update() -> Result<()> {
let config = InstallConfig::load()?.unwrap_or_default();
config.save()?;
run_uv_install(&config, true)?;
record_installed_sha(&config);
println!("omnidev: updated omnigent ({})", config.spec());
Ok(())
}
/// After a successful install, capture the remote sha of the tracked ref and
/// stash it in the cache so `check` has a baseline even before the dist-info
/// reader runs. Best-effort — failures here never fail the install.
fn record_installed_sha(config: &InstallConfig) {
if let Some(sha) = crate::update_check::remote_sha(&config.repo, &config.git_ref) {
let _ = crate::update_check::set_installed_sha(&sha);
}
}
/// Read the commit the installed omnigent tool was built from, via its PEP 610
/// `direct_url.json`. Returns `None` for a non-VCS install or when uv/metadata
/// can't be read. Never touches the working directory.
pub fn installed_commit() -> Option<String> {
let dir = uv_tool_dir()?;
// …/omnigent/**/omnigent-*.dist-info/direct_url.json
let omnigent_root = dir.join("omnigent");
let dist_info = find_dist_info(&omnigent_root)?;
let text = std::fs::read_to_string(dist_info.join("direct_url.json")).ok()?;
let value: serde_json::Value = serde_json::from_str(&text).ok()?;
value
.get("vcs_info")?
.get("commit_id")?
.as_str()
.map(str::to_string)
}
fn uv_tool_dir() -> Option<PathBuf> {
let output = Command::new("uv").args(["tool", "dir"]).output().ok()?;
if !output.status.success() {
return None;
}
let path = String::from_utf8(output.stdout).ok()?;
let trimmed = path.trim();
if trimmed.is_empty() {
None
} else {
Some(PathBuf::from(trimmed))
}
}
/// Find the `omnigent-*.dist-info` dir under a uv tool's environment. uv lays
/// tools out as `<tool>/lib/pythonX.Y/site-packages/<pkg>-<ver>.dist-info`, so
/// we walk rather than hardcode the python version.
fn find_dist_info(root: &std::path::Path) -> Option<PathBuf> {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with("omnigent-") && name.ends_with(".dist-info") {
return Some(path);
}
stack.push(path);
}
}
None
}
/// Locate an executable on PATH (portable `which`, no external dep).
fn which(program: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(program);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
+114
View File
@@ -0,0 +1,114 @@
//! LAN origin discovery for device testing.
//!
//! When Vite binds to `0.0.0.0` (`--vite-host 0.0.0.0`), a phone or tablet on
//! the same network loads the UI at `http://<lan-ip>:<vite-port>`. Its browser
//! stamps that non-loopback address as the `Origin` on every request. The
//! backend runs in local single-user mode, where the origin guard
//! (`omnigent.server.ws_origin.origin_allowed`) admits only loopback origins —
//! so multipart uploads get a 403 and the WebSocket stream is refused.
//!
//! `--trust-lan-origins` closes that gap by enumerating this machine's LAN
//! IPv4 addresses and handing the server the matching `http://<ip>:<port>`
//! origins via `OMNIGENT_WS_ALLOWED_ORIGINS` — the server's own exact-match
//! allowlist. It stays exact-match (no security disable): only the origins we
//! name are trusted.
use std::net::Ipv4Addr;
/// Whether an IPv4 address is a usable LAN address to trust as an origin.
///
/// Keeps private (RFC 1918) and link-local (169.254/16) addresses — the ones a
/// device on the same network actually reaches this machine by. Drops loopback
/// (already trusted), unspecified (`0.0.0.0`), broadcast, documentation, and
/// multicast, none of which a real device browses to.
fn is_lan_ipv4(ip: &Ipv4Addr) -> bool {
(ip.is_private() || ip.is_link_local())
&& !ip.is_loopback()
&& !ip.is_unspecified()
&& !ip.is_broadcast()
&& !ip.is_multicast()
}
/// Build the `http://<ip>:<port>` origins to trust for a given set of LAN
/// IPv4 addresses.
///
/// Split out from interface enumeration so the origin-shaping (which is all we
/// assert on) is testable without touching the host's real interfaces. The
/// input is deduplicated and the output is sorted for a stable env value.
fn origins_for_ips(ips: impl IntoIterator<Item = Ipv4Addr>, vite_port: u16) -> Vec<String> {
let mut origins: Vec<String> = ips
.into_iter()
.filter(is_lan_ipv4)
.map(|ip| format!("http://{ip}:{vite_port}"))
.collect();
origins.sort();
origins.dedup();
origins
}
/// Discover the `http://<lan-ip>:<vite-port>` origins for this machine's LAN
/// interfaces.
///
/// Returns an empty vector when no LAN interface is found (e.g. offline) — the
/// caller then simply trusts nothing extra rather than failing. Interface
/// enumeration errors are treated the same way: LAN trust is a convenience, so
/// a lookup failure must not block the pod from starting.
pub fn trusted_lan_origins(vite_port: u16) -> Vec<String> {
let ips = match if_addrs::get_if_addrs() {
Ok(ifaces) => ifaces
.into_iter()
.filter_map(|iface| match iface.addr.ip() {
std::net::IpAddr::V4(v4) => Some(v4),
std::net::IpAddr::V6(_) => None,
}),
Err(_) => return Vec::new(),
};
origins_for_ips(ips, vite_port)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_private_and_link_local_drops_loopback_and_public() {
assert!(is_lan_ipv4(&Ipv4Addr::new(192, 168, 1, 42)));
assert!(is_lan_ipv4(&Ipv4Addr::new(10, 0, 0, 5)));
assert!(is_lan_ipv4(&Ipv4Addr::new(172, 16, 3, 9)));
assert!(is_lan_ipv4(&Ipv4Addr::new(169, 254, 10, 1)));
assert!(!is_lan_ipv4(&Ipv4Addr::new(127, 0, 0, 1)));
assert!(!is_lan_ipv4(&Ipv4Addr::new(0, 0, 0, 0)));
assert!(!is_lan_ipv4(&Ipv4Addr::new(8, 8, 8, 8)));
assert!(!is_lan_ipv4(&Ipv4Addr::new(255, 255, 255, 255)));
}
#[test]
fn builds_http_origins_with_the_vite_port() {
let origins = origins_for_ips([Ipv4Addr::new(192, 168, 1, 42)], 5173);
assert_eq!(origins, vec!["http://192.168.1.42:5173"]);
}
#[test]
fn filters_and_sorts_and_dedups() {
let origins = origins_for_ips(
[
Ipv4Addr::new(10, 0, 0, 9),
Ipv4Addr::new(127, 0, 0, 1), // loopback dropped
Ipv4Addr::new(8, 8, 8, 8), // public dropped
Ipv4Addr::new(192, 168, 1, 5),
Ipv4Addr::new(10, 0, 0, 9), // duplicate collapsed
],
8080,
);
assert_eq!(
origins,
vec!["http://10.0.0.9:8080", "http://192.168.1.5:8080"]
);
}
#[test]
fn no_lan_interfaces_yields_no_origins() {
assert!(origins_for_ips([Ipv4Addr::new(127, 0, 0, 1)], 5173).is_empty());
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Single-instance guard per pod.
//!
//! Two omnidev runs in the same checkout resolve to the same pod dir (the dir
//! is keyed to the canonical repo root), so their processes would fight over
//! the same ports and state. An advisory `flock` on a file in the pod dir lets
//! only the first in. The lock is held for the process lifetime and released
//! by the OS on exit or crash — no stale-file cleanup needed.
use std::fs::{File, OpenOptions};
use std::os::fd::AsRawFd;
use std::path::Path;
use anyhow::{bail, Context, Result};
/// An acquired pod lock. Dropping it (on process exit) releases the flock.
pub struct PodLock {
_file: File,
}
/// Try to take the pod's exclusive lock. Returns an error naming the pod dir if
/// another omnidev already holds it.
pub fn acquire(pod_dir: &Path) -> Result<PodLock> {
let path = pod_dir.join("omnidev.lock");
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&path)
.with_context(|| format!("opening lock file {}", path.display()))?;
// Non-blocking exclusive lock: EWOULDBLOCK means a peer holds it.
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc != 0 {
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
bail!(
"another omnidev is already running for this checkout (pod {}). \
Quit it first, or run in a different worktree.",
pod_dir.display()
);
}
return Err(err).with_context(|| format!("locking {}", path.display()));
}
Ok(PodLock { _file: file })
}
+58
View File
@@ -0,0 +1,58 @@
//! Per-process bounded log buffers with write-through to disk.
use std::collections::VecDeque;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::Path;
const MAX_LINES: usize = 5000;
/// A bounded ring buffer of log lines for one channel, mirrored to a file so
/// the full session output survives for later inspection (`tail`, editor).
pub struct LogBuffer {
lines: VecDeque<String>,
file: Option<File>,
/// Monotonic count of lines ever appended — lets panes detect growth for
/// follow-tail without diffing the buffer.
pub total: u64,
}
impl LogBuffer {
pub fn new(path: &Path) -> Self {
let file = OpenOptions::new().create(true).append(true).open(path).ok();
LogBuffer {
lines: VecDeque::with_capacity(MAX_LINES),
file,
total: 0,
}
}
/// In-memory only channel (e.g. the synthetic "omnidev" event log).
pub fn memory() -> Self {
LogBuffer {
lines: VecDeque::with_capacity(256),
file: None,
total: 0,
}
}
pub fn push(&mut self, line: impl Into<String>) {
let line = line.into();
if let Some(f) = self.file.as_mut() {
let _ = writeln!(f, "{line}");
}
if self.lines.len() == MAX_LINES {
self.lines.pop_front();
}
self.lines.push_back(line);
self.total = self.total.saturating_add(1);
}
pub fn clear(&mut self) {
self.lines.clear();
}
pub fn iter(&self) -> impl Iterator<Item = &String> {
self.lines.iter()
}
}
+211
View File
@@ -0,0 +1,211 @@
//! omnidev — dev tooling for Omnigent.
//!
//! Two independent capabilities in one binary:
//! - **pod supervisor** (bare `omnidev`): manages an isolated dev instance for
//! the current checkout — server/host/vite, restarting the backend on Python
//! changes while Vite handles frontend HMR.
//! - **install management** (`omnidev install`/`update`/`check`/…): install and
//! keep a git-based omnigent up to date. These need no checkout and run
//! anywhere.
mod install;
mod lan;
mod lock;
mod logs;
mod paths;
mod pod;
mod ports;
mod process;
mod shellhook;
mod state;
mod supervisor;
mod tui;
mod update_check;
mod watcher;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use clap::{Parser, Subcommand};
use tokio::sync::mpsc;
use install::InstallConfig;
use pod::Pod;
use ports::Ports;
use state::Shared;
use supervisor::{Cmd, Supervisor};
#[derive(Parser, Debug)]
#[command(name = "omnidev", about = "Dev tooling for Omnigent", version)]
struct Args {
#[command(subcommand)]
command: Option<Command>,
#[command(flatten)]
run: RunArgs,
}
/// Flags for the default (no-subcommand) pod-supervisor run.
#[derive(clap::Args, Debug)]
struct RunArgs {
/// Force the backend server port (default: probe from 6767).
#[arg(long)]
server_port: Option<u16>,
/// Force the Vite dev-server port (default: probe from 5173).
#[arg(long)]
vite_port: Option<u16>,
/// Vite dev-server bind host (default: 127.0.0.1; use 0.0.0.0 for LAN access).
#[arg(long, default_value = "127.0.0.1")]
vite_host: String,
/// Trust this machine's LAN origins so a phone/tablet on the same network
/// can use the UI (uploads + live stream). Pairs with `--vite-host 0.0.0.0`.
#[arg(long)]
trust_lan_origins: bool,
/// Use this pod directory instead of the per-repo default.
#[arg(long)]
pod_dir: Option<PathBuf>,
/// Do not start the Vite frontend (backend + host only).
#[arg(long)]
no_vite: bool,
/// Wipe the pod directory before starting.
#[arg(long)]
clean: bool,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Install omnigent from git (defaults to the databricks extra, main).
Install {
/// Git ref (branch/tag/sha) to track.
#[arg(long, default_value = install::DEFAULT_REF)]
r#ref: String,
/// Extra to include (repeatable). Defaults to `databricks`.
#[arg(long = "extra")]
extras: Vec<String>,
/// Omit the default databricks extra (install with no extras).
#[arg(long)]
no_default_extra: bool,
/// Git repo URL.
#[arg(long, default_value = install::DEFAULT_REPO)]
repo: String,
},
/// Reinstall the latest of the tracked ref/extras.
Update,
/// Check for an omnigent update (the shell hook calls this).
Check {
/// Print nothing when already up to date.
#[arg(long)]
quiet: bool,
},
/// Refresh the update-check cache from the network (usually run detached).
Refresh,
/// Print a shell snippet to eval from .zshrc/.bashrc for daily checks.
ShellHook,
}
fn main() -> Result<()> {
let args = Args::parse();
// Install-management subcommands manage a global tool and must work from
// anywhere — dispatch them before any checkout discovery.
match args.command {
Some(Command::Install {
r#ref,
extras,
no_default_extra,
repo,
}) => {
let extras = if !extras.is_empty() {
extras
} else if no_default_extra {
vec![]
} else {
vec![install::DEFAULT_EXTRA.to_string()]
};
let config = InstallConfig {
repo,
git_ref: r#ref,
extras,
};
install::install(&config)
}
Some(Command::Update) => install::update(),
Some(Command::Check { quiet }) => update_check::check(quiet),
Some(Command::Refresh) => update_check::refresh(),
Some(Command::ShellHook) => {
shellhook::print();
Ok(())
}
None => run_supervisor(args.run),
}
}
/// Default path: the pod supervisor for the current checkout. This is the only
/// path that requires an Omnigent checkout.
#[tokio::main]
async fn run_supervisor(args: RunArgs) -> Result<()> {
let cwd = std::env::current_dir()?;
let repo_root = paths::find_repo_root(&cwd)?;
let pod_dir = match &args.pod_dir {
Some(p) => p.clone(),
None => paths::default_pod_dir(&repo_root)?,
};
if args.clean {
pod::clean(&pod_dir)?;
}
std::fs::create_dir_all(&pod_dir)?;
// Only one omnidev per pod — same-checkout runs share this dir and would
// otherwise fight over ports and state. Held until the process exits.
let _lock = lock::acquire(&pod_dir)?;
let ports = Ports::resolve(&pod_dir, args.server_port, args.vite_port)?;
// LAN origins are keyed to the resolved Vite port, so compute them here
// once the port is known. Empty unless `--trust-lan-origins` is set.
let trusted_origins = if args.trust_lan_origins {
lan::trusted_lan_origins(ports.vite)
} else {
Vec::new()
};
let pod = Arc::new(Pod::create(
repo_root,
pod_dir,
ports,
args.vite_host,
trusted_origins,
)?);
let shared = Shared::new(&pod);
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<Cmd>();
// File watcher: Python changes -> Reload commands. Keep the debouncer alive
// for the whole session.
let _watcher = watcher::spawn(&pod.omnigent_dir(), cmd_tx.clone())?;
// Supervisor runs on the tokio runtime; the TUI drives it via cmd_tx.
let supervisor = Supervisor::new(
pod.clone(),
shared.clone(),
!args.no_vite,
args.trust_lan_origins,
);
let sup_handle = tokio::spawn(supervisor.run(cmd_rx));
// Run the TUI (owns the terminal) until the user quits.
let app = tui::App::new(pod.clone(), shared.clone(), cmd_tx.clone());
let result = app.run().await;
// Tear down children, then wait for the supervisor to finish shutdown.
let _ = cmd_tx.send(Cmd::Shutdown);
let _ = sup_handle.await;
result
}
+93
View File
@@ -0,0 +1,93 @@
//! Repo-root discovery and per-repo pod-directory resolution.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
/// Walk up from `start` looking for the checkout root.
///
/// The root is the first ancestor holding a `.jj/` or `.git/` marker — the VCS
/// root. We then require `web/` and `omnigent/` to be present so we fail early
/// on an unrelated repo rather than mid-spawn.
pub fn find_repo_root(start: &Path) -> Result<PathBuf> {
let start = start
.canonicalize()
.with_context(|| format!("resolving start dir {}", start.display()))?;
let mut cur: Option<&Path> = Some(&start);
while let Some(dir) = cur {
if dir.join(".jj").is_dir() || dir.join(".git").exists() {
let root = dir.to_path_buf();
if !root.join("omnigent").is_dir() || !root.join("web").is_dir() {
bail!(
"found a VCS root at {} but it lacks omnigent/ and web/ — \
run omnidev from inside an Omnigent checkout",
root.display()
);
}
return Ok(root);
}
cur = dir.parent();
}
bail!(
"could not find a checkout root above {} (no .jj or .git marker)",
start.display()
)
}
/// Stable per-repo pod directory: `${XDG_CACHE_HOME:-~/.cache}/omnidev/<slug>-<hash8>/`.
///
/// The hash of the canonical repo path keeps two worktrees on distinct pods;
/// the slug (repo basename) keeps the path human-readable.
pub fn default_pod_dir(repo_root: &Path) -> Result<PathBuf> {
let cache = cache_home()?;
let slug = repo_root
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "repo".to_string());
let hash = short_hash(repo_root.to_string_lossy().as_bytes());
Ok(cache.join("omnidev").join(format!("{slug}-{hash}")))
}
/// `${XDG_CACHE_HOME:-~/.cache}`.
pub fn cache_home() -> Result<PathBuf> {
if let Some(x) = std::env::var_os("XDG_CACHE_HOME") {
if !x.is_empty() {
return Ok(PathBuf::from(x));
}
}
let home = std::env::var_os("HOME").context("HOME is not set")?;
Ok(PathBuf::from(home).join(".cache"))
}
/// `${XDG_CONFIG_HOME:-~/.config}`.
pub fn config_home() -> Result<PathBuf> {
if let Some(x) = std::env::var_os("XDG_CONFIG_HOME") {
if !x.is_empty() {
return Ok(PathBuf::from(x));
}
}
let home = std::env::var_os("HOME").context("HOME is not set")?;
Ok(PathBuf::from(home).join(".config"))
}
/// `~/.config/omnidev/install.toml` — durable record of install intent.
pub fn install_config_path() -> Result<PathBuf> {
Ok(config_home()?.join("omnidev").join("install.toml"))
}
/// `~/.cache/omnidev/omnigent-check.json` — volatile update-check state.
pub fn check_cache_path() -> Result<PathBuf> {
Ok(cache_home()?.join("omnidev").join("omnigent-check.json"))
}
/// FNV-1a 64-bit, rendered as 8 hex chars. No external dep needed — we only
/// need a stable, collision-unlikely tag for a filesystem path.
fn short_hash(bytes: &[u8]) -> String {
let mut hash: u64 = 0xcbf29ce484222325;
for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{:08x}", (hash ^ (hash >> 32)) as u32)
}
+348
View File
@@ -0,0 +1,348 @@
//! A `Pod` = one isolated dev instance: its own state dir, ports, and the env
//! map injected into every supervised child.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::ports::Ports;
pub struct Pod {
pub repo_root: PathBuf,
pub dir: PathBuf,
pub ports: Ports,
pub vite_host: String,
/// LAN origins to trust for device testing (`--trust-lan-origins`); empty
/// otherwise. Fed to the server as `OMNIGENT_WS_ALLOWED_ORIGINS`.
pub trusted_origins: Vec<String>,
}
impl Pod {
/// Create the pod directory tree (idempotent) and return the pod handle.
/// Only omnigent's own state is isolated (DB, artifacts, logs, config); the
/// pod inherits your real home, credentials, and caches.
pub fn create(
repo_root: PathBuf,
dir: PathBuf,
ports: Ports,
vite_host: String,
trusted_origins: Vec<String>,
) -> Result<Pod> {
for sub in ["data/omnigent", "artifacts", "logs", "config"] {
let p = dir.join(sub);
std::fs::create_dir_all(&p)
.with_context(|| format!("creating pod dir {}", p.display()))?;
}
let pod = Pod {
repo_root,
dir,
ports,
vite_host,
trusted_origins,
};
// Seed the pod's config from the developer's real one so it works out
// of the box (keeps their providers). Best-effort: a copy failure just
// starts the pod with an empty config, so warn rather than abort.
if let Some(src) = real_config_path() {
let dest = pod.config_dir().join("config.yaml");
if let Err(e) = seed_config_file(&src, &dest) {
eprintln!("omnidev: could not seed pod config: {e:#}");
}
}
Ok(pod)
}
pub fn db_uri(&self) -> String {
format!(
"sqlite:///{}",
self.dir.join("data/omnigent/chat.db").display()
)
}
pub fn artifacts_dir(&self) -> PathBuf {
self.dir.join("artifacts")
}
/// The pod's isolated config home, exposed to children as
/// `OMNIGENT_CONFIG_HOME` so its `config.yaml` is separate from the
/// developer's real `~/.omnigent/config.yaml`.
pub fn config_dir(&self) -> PathBuf {
self.dir.join("config")
}
pub fn server_url(&self) -> String {
format!("http://127.0.0.1:{}", self.ports.server)
}
/// Clickable URLs for display. Terminals linkify `localhost` but often not
/// a bare `127.0.0.1`. Functional uses (server bind, host `--server`,
/// `OMNIGENT_URL`) stay on `127.0.0.1` so we don't accidentally target IPv6
/// `localhost` (`::1`), where the server isn't listening.
pub fn server_display_url(&self) -> String {
format!("http://localhost:{}", self.ports.server)
}
pub fn vite_display_url(&self) -> String {
format!("http://localhost:{}", self.ports.vite)
}
pub fn web_dir(&self) -> PathBuf {
self.repo_root.join("web")
}
/// Whether `web/` needs `npm install` before Vite can start: either
/// `node_modules/` is absent, or the lockfile / `package.json` is newer
/// than the installed tree (a dependency was added/changed since the last
/// install — the case that makes Vite's dependency scan fail).
pub fn needs_npm_install(&self) -> bool {
let web = self.web_dir();
let modules = web.join("node_modules");
if !modules.is_dir() {
return true;
}
let mtime = |p: PathBuf| std::fs::metadata(p).and_then(|m| m.modified()).ok();
let Some(installed) = mtime(modules) else {
return true;
};
// Reinstall if either manifest is newer than node_modules.
[web.join("package-lock.json"), web.join("package.json")]
.into_iter()
.filter_map(mtime)
.any(|t| t > installed)
}
/// Directory to watch for backend source changes.
pub fn omnigent_dir(&self) -> PathBuf {
self.repo_root.join("omnigent")
}
pub fn log_file(&self, name: &str) -> PathBuf {
self.dir.join("logs").join(format!("{name}.log"))
}
/// The env overrides applied on top of the inherited parent env for every
/// child. We isolate omnigent's own state — the DB, data dir, and config
/// home — so concurrent pods don't share a database, pidfile, or
/// `config.yaml`. The rest (real `HOME`, credentials, uv/npm caches) is
/// inherited, since the agents omnigent runs need it. `OMNIGENT_URL` is the
/// seam `web/vite.config.ts` reads to point its proxy at this pod's backend;
/// `OMNIGENT_CONFIG_HOME` is where the server/host/runner read `config.yaml`.
pub fn env(&self) -> Vec<(String, String)> {
let d = |p: &str| self.dir.join(p).display().to_string();
let mut env = vec![
("OMNIGENT_DATA_DIR".into(), d("data/omnigent")),
("OMNIGENT_DATABASE_URI".into(), self.db_uri()),
("OMNIGENT_URL".into(), self.server_url()),
(
"OMNIGENT_CONFIG_HOME".into(),
self.config_dir().display().to_string(),
),
];
if let Some(allowed) = self.allowed_origins_env() {
env.push(("OMNIGENT_WS_ALLOWED_ORIGINS".into(), allowed));
}
env
}
/// The `OMNIGENT_WS_ALLOWED_ORIGINS` value to inject, or `None` to leave it
/// untouched. Merges the trusted LAN origins onto any value inherited from
/// the parent environment (comma-separated, order-preserving, deduped) so a
/// developer's own allowlist survives. Returns `None` when there are no LAN
/// origins to add — then the parent's value (if any) simply passes through.
fn allowed_origins_env(&self) -> Option<String> {
if self.trusted_origins.is_empty() {
return None;
}
let inherited = std::env::var("OMNIGENT_WS_ALLOWED_ORIGINS").unwrap_or_default();
let mut merged: Vec<String> = Vec::new();
let parts = inherited
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.chain(self.trusted_origins.iter().cloned());
for part in parts {
if !merged.contains(&part) {
merged.push(part);
}
}
Some(merged.join(","))
}
}
/// Remove a pod directory (for `--clean`). No-op if it does not exist.
pub fn clean(dir: &Path) -> Result<()> {
if dir.exists() {
std::fs::remove_dir_all(dir)
.with_context(|| format!("removing pod dir {}", dir.display()))?;
}
Ok(())
}
/// The developer's real omnigent `config.yaml` to seed a fresh pod from.
///
/// Honors `OMNIGENT_CONFIG_HOME` if the parent env sets it (nested/test
/// setups), else `~/.omnigent/config.yaml` via `HOME`. Returns `None` when the
/// file does not exist — a fresh pod then starts with an empty config, just
/// like a first-run user.
fn real_config_path() -> Option<PathBuf> {
let home = match std::env::var_os("OMNIGENT_CONFIG_HOME") {
Some(h) if !h.is_empty() => PathBuf::from(h),
_ => PathBuf::from(std::env::var_os("HOME")?).join(".omnigent"),
};
let path = home.join("config.yaml");
path.exists().then_some(path)
}
/// Copy `src` to `dest`, but only when `dest` does not already exist — a normal
/// pod restart must not clobber config the developer edited inside the pod.
/// After `--clean` the whole pod dir is gone, so `dest` is absent and this
/// re-seeds.
fn seed_config_file(src: &Path, dest: &Path) -> Result<()> {
if dest.exists() {
return Ok(());
}
std::fs::copy(src, dest)
.with_context(|| format!("seeding {} from {}", dest.display(), src.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
// `real_config_path` reads process-global env; serialize the tests that
// set it so parallel runs don't observe each other's overrides.
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn tempdir() -> PathBuf {
let unique = format!(
"omnidev-pod-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let dir = std::env::temp_dir().join(unique);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn make_pod(pod_dir: PathBuf) -> Pod {
Pod::create(
tempdir(),
pod_dir,
Ports {
server: 19191,
vite: 19292,
},
"127.0.0.1".into(),
Vec::new(),
)
.unwrap()
}
/// Point `OMNIGENT_CONFIG_HOME` at `home` for the duration of `f`, restoring
/// the previous value afterwards. Serialized against other env-touching
/// tests via `ENV_LOCK`.
fn with_config_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var_os("OMNIGENT_CONFIG_HOME");
std::env::set_var("OMNIGENT_CONFIG_HOME", home);
let out = f();
match prev {
Some(v) => std::env::set_var("OMNIGENT_CONFIG_HOME", v),
None => std::env::remove_var("OMNIGENT_CONFIG_HOME"),
}
out
}
#[test]
fn create_makes_config_dir() {
let real = tempdir(); // empty config home -> nothing to seed
let pod = with_config_home(&real, || make_pod(tempdir()));
assert!(pod.config_dir().is_dir());
}
#[test]
fn env_includes_config_home() {
let real = tempdir();
let pod = with_config_home(&real, || make_pod(tempdir()));
let env = pod.env();
let got = env
.iter()
.find(|(k, _)| k == "OMNIGENT_CONFIG_HOME")
.map(|(_, v)| v.clone());
assert_eq!(got, Some(pod.config_dir().display().to_string()));
}
#[test]
fn create_seeds_pod_config_from_real() {
let real = tempdir();
std::fs::write(real.join("config.yaml"), "providers:\n seeded: true\n").unwrap();
let pod = with_config_home(&real, || make_pod(tempdir()));
let seeded = std::fs::read_to_string(pod.config_dir().join("config.yaml")).unwrap();
assert_eq!(seeded, "providers:\n seeded: true\n");
}
#[test]
fn create_skips_seed_when_real_config_absent() {
let real = tempdir(); // no config.yaml inside
let pod = with_config_home(&real, || make_pod(tempdir()));
assert!(!pod.config_dir().join("config.yaml").exists());
}
#[test]
fn seed_does_not_overwrite_existing() {
let dir = tempdir();
let src = dir.join("src.yaml");
let dest = dir.join("dest.yaml");
std::fs::write(&src, "from: real\n").unwrap();
std::fs::write(&dest, "edited: in-pod\n").unwrap();
seed_config_file(&src, &dest).unwrap();
// Existing pod-local edits survive; the real config does not clobber them.
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "edited: in-pod\n");
}
#[test]
fn real_config_path_honors_config_home() {
let real = tempdir();
std::fs::write(real.join("config.yaml"), "x: 1\n").unwrap();
let got = with_config_home(&real, real_config_path);
assert_eq!(got, Some(real.join("config.yaml")));
}
#[test]
fn real_config_path_falls_back_to_home_dot_omnigent() {
// With no OMNIGENT_CONFIG_HOME, the real config resolves under
// `$HOME/.omnigent/` — the path a normal pod run seeds from.
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev_cfg = std::env::var_os("OMNIGENT_CONFIG_HOME");
let prev_home = std::env::var_os("HOME");
let home = tempdir();
std::fs::create_dir_all(home.join(".omnigent")).unwrap();
std::fs::write(home.join(".omnigent/config.yaml"), "y: 2\n").unwrap();
std::env::remove_var("OMNIGENT_CONFIG_HOME");
std::env::set_var("HOME", &home);
let got = real_config_path();
match prev_cfg {
Some(v) => std::env::set_var("OMNIGENT_CONFIG_HOME", v),
None => std::env::remove_var("OMNIGENT_CONFIG_HOME"),
}
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
assert_eq!(got, Some(home.join(".omnigent/config.yaml")));
}
}
+124
View File
@@ -0,0 +1,124 @@
//! Free-port probing and per-pod persistence.
use std::collections::HashSet;
use std::net::TcpListener;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub const SERVER_PORT_BASE: u16 = 6767;
pub const VITE_PORT_BASE: u16 = 5173;
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Ports {
pub server: u16,
pub vite: u16,
}
impl Ports {
/// Resolve the pod's ports: reuse the persisted pair if still available,
/// else probe upward from the preferred bases. Explicit overrides (from CLI
/// flags) are honored verbatim.
///
/// A port is "available" only if it both binds right now *and* isn't already
/// claimed by another pod. The bind check alone is racy: `resolve()` runs at
/// startup, before children spawn, so a peer pod whose server/vite hasn't
/// bound yet would leave the base port looking free and two pods would pick
/// it. We read sibling pods' persisted `pod.toml` to skip ports they've
/// already claimed, which is timing-independent.
pub fn resolve(
pod_dir: &Path,
server_override: Option<u16>,
vite_override: Option<u16>,
) -> Result<Ports> {
let persisted = load(pod_dir);
let mut taken = sibling_claims(pod_dir);
let server = match server_override {
Some(p) => p,
None => {
let reuse = persisted
.map(|p| p.server)
.filter(|&p| available(p, &taken));
reuse
.map(Ok)
.unwrap_or_else(|| probe_from(SERVER_PORT_BASE, &taken))?
}
};
// The server port is now spoken for — don't hand the same number to vite.
taken.insert(server);
let vite = match vite_override {
Some(p) => p,
None => {
let reuse = persisted.map(|p| p.vite).filter(|&p| available(p, &taken));
reuse
.map(Ok)
.unwrap_or_else(|| probe_from(VITE_PORT_BASE, &taken))?
}
};
let ports = Ports { server, vite };
save(pod_dir, &ports)?;
Ok(ports)
}
}
/// A port is usable if it isn't already claimed by a sibling pod and binds now.
fn available(port: u16, taken: &HashSet<u16>) -> bool {
!taken.contains(&port) && is_free(port)
}
/// True if the port can be bound on loopback right now.
fn is_free(port: u16) -> bool {
TcpListener::bind(("127.0.0.1", port)).is_ok()
}
/// First available port at or above `base`, skipping sibling-claimed ports.
fn probe_from(base: u16, taken: &HashSet<u16>) -> Result<u16> {
for port in base..=u16::MAX {
if available(port, taken) {
return Ok(port);
}
}
anyhow::bail!("no free port at or above {base}")
}
/// Ports claimed in other pods' `pod.toml` under the shared omnidev cache root.
/// Best-effort: unreadable/oddly-nested pod dirs just contribute nothing.
fn sibling_claims(pod_dir: &Path) -> HashSet<u16> {
let mut claimed = HashSet::new();
let Some(root) = pod_dir.parent() else {
return claimed;
};
let Ok(entries) = std::fs::read_dir(root) else {
return claimed;
};
for entry in entries.flatten() {
let dir = entry.path();
if dir == pod_dir || !dir.is_dir() {
continue;
}
if let Some(p) = load(&dir) {
claimed.insert(p.server);
claimed.insert(p.vite);
}
}
claimed
}
fn persist_path(pod_dir: &Path) -> std::path::PathBuf {
pod_dir.join("pod.toml")
}
fn load(pod_dir: &Path) -> Option<Ports> {
let text = std::fs::read_to_string(persist_path(pod_dir)).ok()?;
toml::from_str(&text).ok()
}
fn save(pod_dir: &Path, ports: &Ports) -> Result<()> {
let text = toml::to_string(ports).context("serializing pod.toml")?;
std::fs::write(persist_path(pod_dir), text).context("writing pod.toml")?;
Ok(())
}
+133
View File
@@ -0,0 +1,133 @@
//! Concrete command specs for the three supervised processes.
use std::path::PathBuf;
use crate::pod::Pod;
/// A resolved command line + working dir for one process. Env is applied by the
/// supervisor from `Pod::env()`, so it is not duplicated here.
pub struct ProcSpec {
pub program: String,
pub args: Vec<String>,
pub cwd: PathBuf,
}
impl ProcSpec {
/// `uv run omnigent server --host 127.0.0.1 --port <p> --database-uri <db>
/// --artifact-location <dir>`, from the repo root.
pub fn server(pod: &Pod) -> ProcSpec {
ProcSpec {
program: "uv".into(),
args: vec![
"run".into(),
"omnigent".into(),
"server".into(),
"--host".into(),
"127.0.0.1".into(),
"--port".into(),
pod.ports.server.to_string(),
"--database-uri".into(),
pod.db_uri(),
"--artifact-location".into(),
pod.artifacts_dir().display().to_string(),
],
cwd: pod.repo_root.clone(),
}
}
/// `uv run omnigent host --server http://127.0.0.1:<p>`, from the repo root.
pub fn host(pod: &Pod) -> ProcSpec {
ProcSpec {
program: "uv".into(),
args: vec![
"run".into(),
"omnigent".into(),
"host".into(),
"--server".into(),
pod.server_url(),
],
cwd: pod.repo_root.clone(),
}
}
/// `npm install`, from `web/`. Run before Vite when deps are missing or
/// stale so Vite's dependency scan doesn't fail on an unresolved import.
///
/// `--loglevel http` makes npm emit a line per package fetch even when its
/// stdout is piped (its progress bar is TTY-only), so the pane streams real
/// progress. `--no-fund --no-audit` trims the trailing noise.
pub fn npm_install(pod: &Pod) -> ProcSpec {
ProcSpec {
program: "npm".into(),
args: vec![
"install".into(),
"--no-fund".into(),
"--no-audit".into(),
"--loglevel".into(),
"http".into(),
],
cwd: pod.web_dir(),
}
}
/// `npm run dev -- --host <host> --port <p> --strictPort`, from `web/`.
/// `OMNIGENT_URL` (in the pod env) points Vite's proxy at this pod's backend.
pub fn vite(pod: &Pod) -> ProcSpec {
ProcSpec {
program: "npm".into(),
args: vec![
"run".into(),
"dev".into(),
"--".into(),
"--host".into(),
pod.vite_host.clone(),
"--port".into(),
pod.ports.vite.to_string(),
"--strictPort".into(),
],
cwd: pod.web_dir(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ports::Ports;
#[test]
fn vite_uses_configured_bind_host_but_backend_url_stays_loopback() {
let repo = tempdir();
let pod_dir = tempdir();
let pod = Pod::create(
repo,
pod_dir,
Ports {
server: 19191,
vite: 19292,
},
"0.0.0.0".into(),
Vec::new(),
)
.unwrap();
let vite = ProcSpec::vite(&pod);
let host_flag = vite.args.iter().position(|arg| arg == "--host").unwrap();
assert_eq!(vite.args[host_flag + 1], "0.0.0.0");
assert_eq!(pod.server_url(), "http://127.0.0.1:19191");
}
fn tempdir() -> std::path::PathBuf {
let unique = format!(
"omnidev-process-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let dir = std::env::temp_dir().join(unique);
std::fs::create_dir_all(&dir).unwrap();
dir
}
}
+19
View File
@@ -0,0 +1,19 @@
//! Emit the shell snippet that runs the daily update check.
/// The snippet to append to `.zshrc`/`.bashrc`
/// (`omnidev shell-hook >> ~/.zshrc`). All throttling and prompting live inside
/// `omnidev check`, so this stays trivial and shell-agnostic: run once per
/// interactive shell, quietly, and never fail the shell if it errors.
///
/// It self-guards on `command -v omnidev`, so it's meant to be appended to the
/// rc (a static no-op when omnidev is absent) rather than run via
/// `eval "$(omnidev shell-hook)"`, which would invoke omnidev on every shell
/// startup and error when it isn't on PATH.
const HOOK: &str = r#"# omnidev: daily omnigent update check
if [ -n "${PS1:-}" ] && command -v omnidev >/dev/null 2>&1; then
omnidev check --quiet || true
fi"#;
pub fn print() {
println!("{HOOK}");
}
+111
View File
@@ -0,0 +1,111 @@
//! Shared state between the supervisor and the TUI.
use std::sync::{Arc, Mutex};
use crate::logs::LogBuffer;
use crate::pod::Pod;
/// The three supervised processes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcId {
Server,
Host,
Vite,
}
impl ProcId {
pub const ALL: [ProcId; 3] = [ProcId::Server, ProcId::Host, ProcId::Vite];
pub fn idx(self) -> usize {
match self {
ProcId::Server => 0,
ProcId::Host => 1,
ProcId::Vite => 2,
}
}
pub fn label(self) -> &'static str {
match self {
ProcId::Server => "server",
ProcId::Host => "host",
ProcId::Vite => "vite",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProcStatus {
Idle,
Starting,
Running(u32),
Restarting,
Crashed,
Stopped,
}
impl ProcStatus {
pub fn short(&self) -> &'static str {
match self {
ProcStatus::Idle => "idle",
ProcStatus::Starting => "starting",
ProcStatus::Running(_) => "running",
ProcStatus::Restarting => "restarting",
ProcStatus::Crashed => "crashed",
ProcStatus::Stopped => "stopped",
}
}
}
/// State the TUI renders and the supervisor mutates. Guarded by a std mutex;
/// locks are held only for the duration of a single push/read.
pub struct Shared {
pub status: [ProcStatus; 3],
pub server: LogBuffer,
pub host: LogBuffer,
pub vite: LogBuffer,
/// Combined, source-tagged view — also receives supervisor events.
pub all: LogBuffer,
}
impl Shared {
pub fn new(pod: &Pod) -> Arc<Mutex<Shared>> {
Arc::new(Mutex::new(Shared {
status: [ProcStatus::Idle, ProcStatus::Idle, ProcStatus::Idle],
server: LogBuffer::new(&pod.log_file("server")),
host: LogBuffer::new(&pod.log_file("host")),
vite: LogBuffer::new(&pod.log_file("vite")),
all: LogBuffer::memory(),
}))
}
fn buf_mut(&mut self, id: ProcId) -> &mut LogBuffer {
match id {
ProcId::Server => &mut self.server,
ProcId::Host => &mut self.host,
ProcId::Vite => &mut self.vite,
}
}
pub fn buf(&self, id: ProcId) -> &LogBuffer {
match id {
ProcId::Server => &self.server,
ProcId::Host => &self.host,
ProcId::Vite => &self.vite,
}
}
/// Append a line from a process: goes to its own pane and the combined view.
pub fn log_proc(&mut self, id: ProcId, line: String) {
self.all.push(format!("[{}] {}", id.label(), line));
self.buf_mut(id).push(line);
}
/// Append a supervisor event (starts, restarts, crashes, reloads).
pub fn event(&mut self, line: impl Into<String>) {
self.all.push(format!("[omnidev] {}", line.into()));
}
pub fn set_status(&mut self, id: ProcId, status: ProcStatus) {
self.status[id.idx()] = status;
}
}
+493
View File
@@ -0,0 +1,493 @@
//! Process supervision: spawn/stop/restart the three children, capture their
//! output, and recover from crashes.
use std::collections::HashSet;
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::net::TcpStream;
use tokio::process::Command;
use tokio::sync::mpsc;
use tokio::time::{sleep, timeout};
use crate::pod::Pod;
use crate::process::ProcSpec;
use crate::state::{ProcId, ProcStatus, Shared};
/// Commands the TUI (and watcher) send to the supervisor.
#[derive(Debug, Clone)]
pub enum Cmd {
/// Restart a single process.
Restart(ProcId),
/// Restart the backend pair: server, then host after `/health`.
RestartBackend,
/// A backend reload triggered by `n` changed Python files.
Reload(usize),
/// Tear everything down and stop the supervisor loop.
Shutdown,
}
/// Reported by a per-child monitor when the child exits.
struct Exit {
id: ProcId,
generation: u64,
status: String,
}
struct Slot {
/// Group id (== leader pid) of the currently-running child, if any.
pgid: Option<i32>,
/// Generation of the current child; bumped on each spawn.
generation: u64,
/// Consecutive crash count for backoff; reset after a stable run.
crashes: u32,
started: Instant,
}
impl Default for Slot {
fn default() -> Self {
Slot {
pgid: None,
generation: 0,
crashes: 0,
started: Instant::now(),
}
}
}
pub struct Supervisor {
pod: Arc<Pod>,
shared: Arc<Mutex<Shared>>,
env: Vec<(String, String)>,
vite_enabled: bool,
/// Whether `--trust-lan-origins` was requested, so we can warn if it was
/// asked for but no LAN interface turned up any origins to trust.
trust_lan_origins: bool,
slots: [Slot; 3],
/// Generations we stopped on purpose — their exits are not crashes.
expected_stops: HashSet<(usize, u64)>,
gen_counter: u64,
exit_tx: mpsc::UnboundedSender<Exit>,
exit_rx: mpsc::UnboundedReceiver<Exit>,
}
impl Supervisor {
pub fn new(
pod: Arc<Pod>,
shared: Arc<Mutex<Shared>>,
vite_enabled: bool,
trust_lan_origins: bool,
) -> Supervisor {
let env = pod.env();
let (exit_tx, exit_rx) = mpsc::unbounded_channel();
Supervisor {
pod,
shared,
env,
vite_enabled,
trust_lan_origins,
slots: Default::default(),
expected_stops: HashSet::new(),
gen_counter: 0,
exit_tx,
exit_rx,
}
}
fn event(&self, msg: impl Into<String>) {
self.shared.lock().unwrap().event(msg);
}
fn set_status(&self, id: ProcId, status: ProcStatus) {
self.shared.lock().unwrap().set_status(id, status);
}
/// Main loop: bring everything up, then service commands and child exits
/// until `Shutdown`.
pub async fn run(mut self, mut cmds: mpsc::UnboundedReceiver<Cmd>) {
self.event(format!(
"pod {} — server :{} vite :{}",
self.pod.dir.display(),
self.pod.ports.server,
self.pod.ports.vite
));
if !self.pod.trusted_origins.is_empty() {
self.event(format!(
"trusting LAN origins for device testing: {}",
self.pod.trusted_origins.join(", ")
));
} else if self.trust_lan_origins {
self.event("--trust-lan-origins: no LAN interface found; no extra origins trusted");
}
self.start_backend().await;
if self.vite_enabled {
self.prepare_vite().await;
self.spawn(ProcId::Vite);
}
loop {
tokio::select! {
cmd = cmds.recv() => {
match cmd {
Some(Cmd::Restart(id)) => self.restart_one(id).await,
Some(Cmd::RestartBackend) => {
self.event("manual backend restart");
self.start_backend_restart().await;
}
Some(Cmd::Reload(n)) => {
self.event(format!("reloading backend ({n} file(s) changed)"));
self.start_backend_restart().await;
}
Some(Cmd::Shutdown) | None => {
self.shutdown().await;
return;
}
}
}
Some(exit) = self.exit_rx.recv() => {
self.on_exit(exit).await;
}
}
}
}
async fn start_backend(&mut self) {
self.spawn(ProcId::Server);
if self.wait_healthy().await {
self.spawn(ProcId::Host);
} else {
self.event("server did not become healthy; host not started");
}
}
/// Restart server then host, gated on `/health`. Used by manual restart and
/// by the reload path.
async fn start_backend_restart(&mut self) {
self.stop(ProcId::Host).await;
self.stop(ProcId::Server).await;
self.set_status(ProcId::Server, ProcStatus::Restarting);
self.set_status(ProcId::Host, ProcStatus::Restarting);
self.spawn(ProcId::Server);
if self.wait_healthy().await {
self.spawn(ProcId::Host);
} else {
self.event("server did not become healthy after restart");
}
}
async fn restart_one(&mut self, id: ProcId) {
match id {
// Restarting the server alone would strand the host on a dead
// backend, so treat it as a backend restart.
ProcId::Server | ProcId::Host => self.start_backend_restart().await,
ProcId::Vite => {
if self.vite_enabled {
self.event("restarting vite");
self.stop(ProcId::Vite).await;
self.prepare_vite().await;
self.spawn(ProcId::Vite);
}
}
}
}
fn spec(&self, id: ProcId) -> ProcSpec {
match id {
ProcId::Server => ProcSpec::server(&self.pod),
ProcId::Host => ProcSpec::host(&self.pod),
ProcId::Vite => ProcSpec::vite(&self.pod),
}
}
/// Spawn a child in its own process group and wire up output + exit monitor.
fn spawn(&mut self, id: ProcId) {
let spec = self.spec(id);
self.set_status(id, ProcStatus::Starting);
let mut cmd = Command::new(&spec.program);
cmd.args(&spec.args)
.current_dir(&spec.cwd)
.envs(self.env.iter().cloned())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(false);
// Become a session/group leader so we can signal the whole tree
// (uvicorn workers, npm -> vite children) via the negative pgid.
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
self.shared
.lock()
.unwrap()
.log_proc(id, format!("failed to spawn {}: {e}", spec.program));
self.set_status(id, ProcStatus::Crashed);
return;
}
};
let pid = child.id().map(|p| p as i32);
self.gen_counter += 1;
let generation = self.gen_counter;
let slot = &mut self.slots[id.idx()];
slot.pgid = pid;
slot.generation = generation;
slot.started = Instant::now();
if let Some(p) = pid {
self.set_status(id, ProcStatus::Running(p as u32));
}
// Merge stdout + stderr into this process's buffer.
if let Some(out) = child.stdout.take() {
self.pump(id, out);
}
if let Some(err) = child.stderr.take() {
self.pump(id, err);
}
// Monitor: report the exit so the loop can decide crash vs expected.
let tx = self.exit_tx.clone();
tokio::spawn(async move {
let status = match child.wait().await {
Ok(s) => s.to_string(),
Err(e) => format!("wait error: {e}"),
};
let _ = tx.send(Exit {
id,
generation,
status,
});
});
}
/// Run `npm install` to completion before Vite starts, but only when deps
/// are missing or stale — otherwise Vite's dependency scan fails on an
/// unresolved import (e.g. a dep added to package.json but not installed).
/// Output streams into the Vite pane. A failed/absent install is logged but
/// non-fatal: we still let Vite try, so a transient npm hiccup doesn't block
/// the whole session.
async fn prepare_vite(&self) {
if !self.pod.needs_npm_install() {
return;
}
self.set_status(ProcId::Vite, ProcStatus::Starting);
self.shared.lock().unwrap().log_proc(
ProcId::Vite,
"web deps missing or stale — running npm install".into(),
);
let spec = ProcSpec::npm_install(&self.pod);
let mut cmd = Command::new(&spec.program);
cmd.args(&spec.args)
.current_dir(&spec.cwd)
.envs(self.env.iter().cloned())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
self.shared
.lock()
.unwrap()
.log_proc(ProcId::Vite, format!("failed to run npm install: {e}"));
return;
}
};
if let Some(out) = child.stdout.take() {
self.pump(ProcId::Vite, out);
}
if let Some(err) = child.stderr.take() {
self.pump(ProcId::Vite, err);
}
// `--loglevel http` streams a line per package fetch, but npm still
// goes quiet during the final tree-build/link phase. A slow heartbeat
// covers those gaps so the pane never looks frozen.
let started = Instant::now();
let mut heartbeat = tokio::time::interval(Duration::from_secs(5));
heartbeat.tick().await; // the first tick fires immediately; skip it
let status = loop {
tokio::select! {
result = child.wait() => break result,
_ = heartbeat.tick() => {
let secs = started.elapsed().as_secs();
self.shared
.lock()
.unwrap()
.log_proc(ProcId::Vite, format!("… npm install running ({secs}s)"));
}
}
};
match status {
Ok(s) if s.success() => self.event(format!(
"npm install complete ({}s)",
started.elapsed().as_secs()
)),
Ok(s) => self.event(format!("npm install exited {s} — starting Vite anyway")),
Err(e) => self.event(format!("npm install wait error: {e}")),
}
}
/// Spawn a task that streams one pipe into the shared buffer, line by line.
fn pump<R>(&self, id: ProcId, reader: R)
where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
let shared = self.shared.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(reader).lines();
while let Ok(Some(line)) = lines.next_line().await {
shared.lock().unwrap().log_proc(id, line);
}
});
}
/// SIGTERM the process group, wait briefly, then SIGKILL. Marks the current
/// generation as an expected stop so its exit is not counted as a crash.
async fn stop(&mut self, id: ProcId) {
let (pgid, generation) = {
let slot = &self.slots[id.idx()];
(slot.pgid, slot.generation)
};
let Some(pgid) = pgid else {
self.set_status(id, ProcStatus::Stopped);
return;
};
self.expected_stops.insert((id.idx(), generation));
unsafe {
libc::kill(-pgid, libc::SIGTERM);
}
// Give the tree up to ~5s to exit on SIGTERM.
for _ in 0..50 {
if unsafe { libc::kill(-pgid, 0) } != 0 {
break;
}
sleep(Duration::from_millis(100)).await;
}
if unsafe { libc::kill(-pgid, 0) } == 0 {
unsafe {
libc::kill(-pgid, libc::SIGKILL);
}
}
self.slots[id.idx()].pgid = None;
self.set_status(id, ProcStatus::Stopped);
}
/// Handle a child exit: distinguish an expected stop from a crash and
/// schedule a backoff restart for crashes.
async fn on_exit(&mut self, exit: Exit) {
let key = (exit.id.idx(), exit.generation);
if self.expected_stops.remove(&key) {
return; // we stopped it on purpose
}
// Ignore exits from a generation we already replaced.
if self.slots[exit.id.idx()].generation != exit.generation {
return;
}
self.slots[exit.id.idx()].pgid = None;
self.set_status(exit.id, ProcStatus::Crashed);
self.event(format!(
"{} exited unexpectedly ({})",
exit.id.label(),
exit.status
));
// Reset the crash counter if the process had been stable for a while.
let crashes = {
let slot = &mut self.slots[exit.id.idx()];
if slot.started.elapsed() > Duration::from_secs(20) {
slot.crashes = 0;
}
slot.crashes += 1;
slot.crashes
};
let backoff = backoff_secs(crashes);
self.event(format!(
"restarting {} in {backoff}s (attempt {crashes})",
exit.id.label(),
));
sleep(Duration::from_secs(backoff)).await;
// A server crash takes the host with it — restart the pair.
match exit.id {
ProcId::Server => self.start_backend_restart().await,
ProcId::Host => {
if self.wait_healthy().await {
self.spawn(ProcId::Host);
} else {
self.start_backend_restart().await;
}
}
ProcId::Vite => {
if self.vite_enabled {
self.spawn(ProcId::Vite);
}
}
}
}
/// Poll the server's `/health` until it returns 200 (up to ~30s).
async fn wait_healthy(&self) -> bool {
let addr = format!("127.0.0.1:{}", self.pod.ports.server);
for _ in 0..120 {
if health_ok(&addr).await {
return true;
}
sleep(Duration::from_millis(250)).await;
}
false
}
async fn shutdown(&mut self) {
self.event("shutting down");
self.stop(ProcId::Host).await;
self.stop(ProcId::Vite).await;
self.stop(ProcId::Server).await;
}
}
fn backoff_secs(attempt: u32) -> u64 {
// 0.5s effectively rounds to 1s here; cap at 30s.
match attempt {
0 | 1 => 1,
2 => 2,
3 => 4,
4 => 8,
5 => 16,
_ => 30,
}
}
/// Minimal HTTP/1.0 `GET /health` returning true on a `200` status line. Avoids
/// pulling an HTTP client dependency just for a readiness probe.
async fn health_ok(addr: &str) -> bool {
let Ok(Ok(mut stream)) = timeout(Duration::from_secs(1), TcpStream::connect(addr)).await else {
return false;
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let req = format!("GET /health HTTP/1.0\r\nHost: {addr}\r\n\r\n");
if stream.write_all(req.as_bytes()).await.is_err() {
return false;
}
let mut buf = [0u8; 128];
let Ok(Ok(n)) = timeout(Duration::from_secs(1), stream.read(&mut buf)).await else {
return false;
};
let head = String::from_utf8_lossy(&buf[..n]);
head.starts_with("HTTP/1.") && head.contains(" 200")
}
+216
View File
@@ -0,0 +1,216 @@
//! Terminal UI: renders pod status + per-process log panes and turns key
//! presses into supervisor commands.
mod render;
use std::io::{self, Stdout};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use tokio::sync::mpsc;
use crate::pod::Pod;
use crate::state::{ProcId, Shared};
use crate::supervisor::Cmd;
/// Which log channel is focused. `All` is the combined, source-tagged view.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum View {
Server,
Host,
Vite,
All,
}
impl View {
fn proc(self) -> Option<ProcId> {
match self {
View::Server => Some(ProcId::Server),
View::Host => Some(ProcId::Host),
View::Vite => Some(ProcId::Vite),
View::All => None,
}
}
}
pub struct App {
pod: Arc<Pod>,
shared: Arc<Mutex<Shared>>,
cmds: mpsc::UnboundedSender<Cmd>,
view: View,
/// Lines scrolled up from the bottom; 0 == pinned to tail.
scroll_back: usize,
follow: bool,
should_quit: bool,
}
impl App {
pub fn new(pod: Arc<Pod>, shared: Arc<Mutex<Shared>>, cmds: mpsc::UnboundedSender<Cmd>) -> App {
App {
pod,
shared,
cmds,
view: View::All,
scroll_back: 0,
follow: true,
should_quit: false,
}
}
/// Run the render + input loop until the user quits. On return, the caller
/// sends `Shutdown` and the terminal is already restored.
pub async fn run(mut self) -> Result<()> {
let mut terminal = setup_terminal()?;
let mut input = spawn_input();
let mut tick = tokio::time::interval(Duration::from_millis(80));
let result = loop {
if let Err(e) = terminal.draw(|f| render::draw(f, &self)) {
break Err(e.into());
}
if self.should_quit {
break Ok(());
}
tokio::select! {
_ = tick.tick() => {}
key = input.recv() => {
match key {
Some(key) => self.on_key(key),
None => break Ok(()),
}
}
}
};
restore_terminal(&mut terminal);
result
}
fn on_key(&mut self, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
let page = 20;
match (key.code, key.modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => self.should_quit = true,
(KeyCode::Char('q'), _) => self.should_quit = true,
(KeyCode::Char('1'), _) => self.set_view(View::Server),
(KeyCode::Char('2'), _) => self.set_view(View::Host),
(KeyCode::Char('3'), _) => self.set_view(View::Vite),
(KeyCode::Char('0'), _) => self.set_view(View::All),
(KeyCode::Tab, _) => self.cycle_view(),
(KeyCode::Up, _) => self.scroll(1),
(KeyCode::Down, _) => self.scroll_down(1),
(KeyCode::PageUp, _) => self.scroll(page),
(KeyCode::PageDown, _) => self.scroll_down(page),
(KeyCode::Char('f'), _) => {
self.follow = !self.follow;
if self.follow {
self.scroll_back = 0;
}
}
(KeyCode::Char('r'), _) => {
if let Some(id) = self.view.proc() {
let _ = self.cmds.send(Cmd::Restart(id));
} else {
let _ = self.cmds.send(Cmd::RestartBackend);
}
}
(KeyCode::Char('R'), _) => {
let _ = self.cmds.send(Cmd::RestartBackend);
}
(KeyCode::Char('c'), _) => self.clear_current(),
_ => {}
}
}
fn set_view(&mut self, v: View) {
self.view = v;
self.scroll_back = 0;
}
fn cycle_view(&mut self) {
self.view = match self.view {
View::All => View::Server,
View::Server => View::Host,
View::Host => View::Vite,
View::Vite => View::All,
};
self.scroll_back = 0;
}
fn scroll(&mut self, n: usize) {
// Scrolling up detaches from the tail.
self.follow = false;
self.scroll_back = self.scroll_back.saturating_add(n);
}
fn scroll_down(&mut self, n: usize) {
self.scroll_back = self.scroll_back.saturating_sub(n);
if self.scroll_back == 0 {
self.follow = true;
}
}
fn clear_current(&mut self) {
let mut s = self.shared.lock().unwrap();
match self.view {
View::Server => s.server.clear(),
View::Host => s.host.clear(),
View::Vite => s.vite.clear(),
View::All => s.all.clear(),
}
self.scroll_back = 0;
}
/// Total line count of the focused channel, for the status readout.
pub fn line_count(&self) -> usize {
let s = self.shared.lock().unwrap();
match self.view {
View::Server => s.buf(ProcId::Server).iter().count(),
View::Host => s.buf(ProcId::Host).iter().count(),
View::Vite => s.buf(ProcId::Vite).iter().count(),
View::All => s.all.iter().count(),
}
}
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
Ok(Terminal::new(CrosstermBackend::new(stdout))?)
}
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) {
let _ = disable_raw_mode();
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
let _ = terminal.show_cursor();
}
/// Read crossterm key events on a dedicated thread and forward them; the async
/// loop selects on this alongside the render tick.
fn spawn_input() -> mpsc::UnboundedReceiver<KeyEvent> {
let (tx, rx) = mpsc::unbounded_channel();
std::thread::spawn(move || loop {
if event::poll(Duration::from_millis(200)).unwrap_or(false) {
if let Ok(Event::Key(key)) = event::read() {
if tx.send(key).is_err() {
break;
}
}
}
});
rx
}
+253
View File
@@ -0,0 +1,253 @@
//! Frame rendering. Minimal chrome: no boxes — regions are separated by a
//! light neutral background bar instead. The header and footer share the
//! "chrome" bar; the log body sits on the terminal's default background so
//! ANSI log colors render naturally on either a light or dark theme.
use ansi_to_tui::IntoText;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Tabs};
use ratatui::Frame;
use super::{App, View};
use crate::state::{ProcId, ProcStatus};
// Palette calibrated (Solarized accents) to stay legible on both light and
// dark terminals. The chrome bars use a light neutral background with dark
// text; the log body keeps the terminal default background so ANSI log colors
// render naturally on either theme. Accent hues are mid-tone so they read on
// the light bar and on both a black and a white body background.
const CHROME_BG: Color = Color::Rgb(238, 232, 213); // light neutral bar
const CHROME_FG: Color = Color::Rgb(60, 70, 72); // dark text on the bar
const MUTED: Color = Color::Rgb(120, 132, 133); // de-emphasized labels
const SERVER: Color = Color::Rgb(38, 139, 210); // blue
const HOST: Color = Color::Rgb(42, 161, 152); // cyan
const VITE: Color = Color::Rgb(211, 54, 130); // magenta
const EVENT: Color = Color::Rgb(181, 137, 0); // amber (omnidev channel)
const OK: Color = Color::Rgb(133, 153, 0); // green (running)
const WARN: Color = Color::Rgb(203, 75, 22); // orange (starting/restarting)
const ERR: Color = Color::Rgb(220, 50, 47); // red (crashed)
/// Style for the header/footer chrome bars.
fn chrome() -> Style {
Style::default().bg(CHROME_BG).fg(CHROME_FG)
}
pub fn draw(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // pod path
Constraint::Length(1), // urls
Constraint::Length(1), // status chips
Constraint::Length(1), // tabs + scroll status
Constraint::Min(1), // body
Constraint::Length(1), // footer
])
.split(f.area());
draw_pod(f, app, chunks[0]);
draw_urls(f, app, chunks[1]);
draw_chips(f, app, chunks[2]);
draw_tabs_row(f, app, chunks[3]);
draw_body(f, app, chunks[4]);
draw_footer(f, chunks[5]);
}
fn draw_pod(f: &mut Frame, app: &App, area: Rect) {
let line = Line::from(vec![
Span::styled(" pod ", Style::default().fg(MUTED)),
Span::raw(app.pod.dir.display().to_string()),
]);
f.render_widget(Paragraph::new(line).style(chrome()), area);
}
fn draw_urls(f: &mut Frame, app: &App, area: Rect) {
let line = Line::from(vec![
Span::styled(" server ", Style::default().fg(MUTED)),
Span::styled(
app.pod.server_display_url(),
Style::default().fg(proc_color(ProcId::Server)),
),
Span::styled(" ui ", Style::default().fg(MUTED)),
Span::styled(
app.pod.vite_display_url(),
Style::default().fg(proc_color(ProcId::Vite)),
),
]);
f.render_widget(Paragraph::new(line).style(chrome()), area);
}
fn draw_chips(f: &mut Frame, app: &App, area: Rect) {
let status = app.shared.lock().unwrap().status.clone();
let mut chips: Vec<Span> = vec![Span::raw(" ")];
for id in ProcId::ALL {
let st = &status[id.idx()];
chips.push(Span::styled(
id.label(),
Style::default()
.fg(proc_color(id))
.add_modifier(Modifier::BOLD),
));
chips.push(Span::raw(" "));
chips.push(Span::styled(
st.short(),
Style::default().fg(status_color(st)),
));
chips.push(Span::raw(" "));
}
f.render_widget(Paragraph::new(Line::from(chips)).style(chrome()), area);
}
fn draw_tabs_row(f: &mut Frame, app: &App, area: Rect) {
// Split the row: tabs on the left, scroll/follow status right-aligned.
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(24)])
.split(area);
let entries = [
("server", View::Server, Some(ProcId::Server)),
("host", View::Host, Some(ProcId::Host)),
("vite", View::Vite, Some(ProcId::Vite)),
("all", View::All, None),
];
let selected = entries
.iter()
.position(|(_, v, _)| *v == app.view)
.unwrap_or(3);
let titles: Vec<Line> = entries
.iter()
.map(|(name, _, id)| {
let color = id.map(proc_color).unwrap_or(CHROME_FG);
Line::from(Span::styled(*name, Style::default().fg(color)))
})
.collect();
let tabs = Tabs::new(titles)
.select(selected)
.style(chrome())
.divider(Span::styled("·", Style::default().fg(MUTED)))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD));
f.render_widget(tabs, cols[0]);
let total = app.line_count();
let status = if app.follow {
format!("{total} ln · follow ")
} else {
format!("{total} ln · ↑{} ", app.scroll_back)
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(status, Style::default().fg(MUTED))))
.alignment(Alignment::Right)
.style(chrome()),
cols[1],
);
}
fn draw_body(f: &mut Frame, app: &App, area: Rect) {
let all_view = app.view == View::All;
let shared = app.shared.lock().unwrap();
let lines: Vec<String> = match app.view {
View::Server => shared.buf(ProcId::Server).iter().cloned().collect(),
View::Host => shared.buf(ProcId::Host).iter().cloned().collect(),
View::Vite => shared.buf(ProcId::Vite).iter().cloned().collect(),
View::All => shared.all.iter().cloned().collect(),
};
drop(shared);
let height = area.height as usize;
let total = lines.len();
let max_back = total.saturating_sub(height);
let back = app.scroll_back.min(max_back);
let end = total.saturating_sub(back);
let start = end.saturating_sub(height);
let rendered: Vec<Line> = lines[start..end]
.iter()
.map(|l| render_line(l, all_view))
.collect();
f.render_widget(Paragraph::new(rendered), area);
}
fn draw_footer(f: &mut Frame, area: Rect) {
let hint = " 1/2/3/0 view · Tab cycle · ↑↓/PgUp/PgDn scroll · f follow · r restart · R backend · c clear · q quit ";
f.render_widget(
Paragraph::new(Line::from(Span::styled(
hint,
Style::default().fg(CHROME_FG),
)))
.style(chrome()),
area,
);
}
/// Turn one stored log line into a styled `Line`. In the combined view the
/// leading `[service]` tag is colored per service and the rest keeps its ANSI
/// colors; per-service panes just pass their ANSI through.
fn render_line(raw: &str, all_view: bool) -> Line<'static> {
if all_view {
if let Some(rest) = raw.strip_prefix('[') {
if let Some(end) = rest.find(']') {
let label = &rest[..end];
let body = &rest[end + 1..];
let mut spans = vec![Span::styled(
format!("[{label}]"),
Style::default()
.fg(label_color(label))
.add_modifier(Modifier::BOLD),
)];
spans.extend(ansi_spans(body));
return Line::from(spans);
}
}
}
Line::from(ansi_spans(raw))
}
/// Parse a single line of possibly-ANSI text into owned spans, falling back to
/// the raw string if it doesn't parse.
fn ansi_spans(s: &str) -> Vec<Span<'static>> {
match s.into_text() {
Ok(text) => text
.lines
.into_iter()
.next()
.map(|l| l.spans)
.unwrap_or_default(),
Err(_) => vec![Span::raw(s.to_string())],
}
}
fn proc_color(id: ProcId) -> Color {
match id {
ProcId::Server => SERVER,
ProcId::Host => HOST,
ProcId::Vite => VITE,
}
}
/// Color for a `[label]` prefix in the combined view — the three services plus
/// the synthetic "omnidev" supervisor channel.
fn label_color(label: &str) -> Color {
match label {
"server" => proc_color(ProcId::Server),
"host" => proc_color(ProcId::Host),
"vite" => proc_color(ProcId::Vite),
"omnidev" => EVENT,
_ => MUTED,
}
}
fn status_color(st: &ProcStatus) -> Color {
match st {
ProcStatus::Running(_) => OK,
ProcStatus::Starting | ProcStatus::Restarting => WARN,
ProcStatus::Crashed => ERR,
ProcStatus::Stopped => VITE,
ProcStatus::Idle => MUTED,
}
}
+228
View File
@@ -0,0 +1,228 @@
//! Daily update check for a git-installed omnigent.
//!
//! Fills a real gap: omnigent's own update notice only works for PyPI-wheel
//! installs and bails on VCS installs. The hot path (`check`) never blocks on
//! the network — it reads a cache and spawns a detached `refresh` when stale.
use std::io::{IsTerminal, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::install::{self, InstallConfig};
use crate::paths;
const STALE_SECS: u64 = 24 * 60 * 60;
const LS_REMOTE_TIMEOUT_SECS: u64 = 5;
/// Volatile update-check state cached between runs.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CheckCache {
#[serde(default)]
pub last_checked: u64,
#[serde(default)]
pub remote_sha: Option<String>,
#[serde(default)]
pub installed_sha: Option<String>,
/// The remote sha we already prompted about, so a declined update isn't
/// re-nagged until a newer commit lands.
#[serde(default)]
pub last_prompted_sha: Option<String>,
}
impl CheckCache {
pub fn load() -> CheckCache {
let Ok(path) = paths::check_cache_path() else {
return CheckCache::default();
};
std::fs::read_to_string(&path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
pub fn save(&self) -> Result<()> {
let path = paths::check_cache_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let text = serde_json::to_string_pretty(self).context("serializing check cache")?;
std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
}
/// Whether the cache indicates an update the user hasn't already declined.
/// Pure so it can be unit-tested without touching disk or the network.
///
/// `installed` is the best-known installed commit (dist-info first, else the
/// cached `installed_sha`). An update is available when we have a remote sha
/// that differs from what's installed and that we haven't already prompted for.
pub fn update_available(cache: &CheckCache, installed: Option<&str>) -> bool {
let Some(remote) = cache.remote_sha.as_deref() else {
return false;
};
if Some(remote) == installed {
return false;
}
if cache.last_prompted_sha.as_deref() == Some(remote) {
return false;
}
true
}
/// Whether `last_checked` is older than the staleness window.
pub fn is_stale(cache: &CheckCache, now: u64) -> bool {
now.saturating_sub(cache.last_checked) > STALE_SECS
}
fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// The remote HEAD sha of `git_ref` in `repo`, via `git ls-remote` (targets the
/// remote, so no local checkout is needed). `None` on any failure/timeout.
pub fn remote_sha(repo: &str, git_ref: &str) -> Option<String> {
// `timeout` isn't portable (absent on macOS by default), so bound the call
// with git's own connect timeout and a wait guard instead.
let mut child = Command::new("git")
.args(["ls-remote", repo, git_ref])
.env("GIT_TERMINAL_PROMPT", "0")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.stdin(Stdio::null())
.spawn()
.ok()?;
let deadline = SystemTime::now() + Duration::from_secs(LS_REMOTE_TIMEOUT_SECS);
loop {
match child.try_wait().ok()? {
Some(_) => break,
None => {
if SystemTime::now() > deadline {
let _ = child.kill();
return None;
}
std::thread::sleep(Duration::from_millis(100));
}
}
}
let output = child.wait_with_output().ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8(output.stdout).ok()?;
// First whitespace-delimited token of the first line is the sha.
text.lines()
.next()
.and_then(|l| l.split_whitespace().next())
.map(str::to_string)
}
/// Record the installed sha into the cache (called after install/update).
pub fn set_installed_sha(sha: &str) -> Result<()> {
let mut cache = CheckCache::load();
cache.installed_sha = Some(sha.to_string());
cache.save()
}
/// `refresh` subcommand: hit the network, update `remote_sha` + `last_checked`.
/// Invoked detached by `check`, but also runnable directly.
pub fn refresh() -> Result<()> {
let config = InstallConfig::load()?.unwrap_or_default();
let mut cache = CheckCache::load();
cache.remote_sha = remote_sha(&config.repo, &config.git_ref);
cache.last_checked = now_epoch();
cache.save()
}
/// Best-known installed commit: the tool's dist-info first (authoritative),
/// else the sha we recorded at install time.
fn installed_commit(cache: &CheckCache) -> Option<String> {
install::installed_commit().or_else(|| cache.installed_sha.clone())
}
/// `check` subcommand: the fast hook primitive. Never blocks on the network.
///
/// - Stale cache ⇒ spawn a detached `refresh` and return.
/// - An available update ⇒ notice; on a TTY, prompt and update in the
/// foreground on yes, else record the decline.
/// - `quiet` suppresses the "up to date" path so shell startup stays silent.
pub fn check(quiet: bool) -> Result<()> {
let cache = CheckCache::load();
if is_stale(&cache, now_epoch()) {
spawn_detached_refresh();
// Still evaluate against whatever we already had cached.
}
let installed = installed_commit(&cache);
if !update_available(&cache, installed.as_deref()) {
if !quiet {
println!("omnigent is up to date.");
}
return Ok(());
}
let remote = cache.remote_sha.clone().unwrap_or_default();
let short = |s: &str| s.chars().take(8).collect::<String>();
let installed_desc = installed
.as_deref()
.map(short)
.unwrap_or_else(|| "unknown".to_string());
eprintln!(
"omnigent update available: {}{} (git)",
installed_desc,
short(&remote),
);
// Only prompt on an interactive terminal; scripts/CI just see the notice.
if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
return Ok(());
}
if prompt_yes_no("Update omnigent now? [y/N] ") {
install::update()?;
} else {
// Don't re-nag for this same commit.
let mut cache = CheckCache::load();
cache.last_prompted_sha = Some(remote);
cache.save()?;
}
Ok(())
}
/// Prompt on the controlling terminal. Reads from `/dev/tty` so it works even
/// when the hook's stdin is redirected. Any read failure ⇒ treated as "no".
fn prompt_yes_no(prompt: &str) -> bool {
use std::io::BufRead;
let Ok(tty) = std::fs::OpenOptions::new().read(true).open("/dev/tty") else {
return false;
};
eprint!("{prompt}");
let _ = std::io::stderr().flush();
let mut line = String::new();
if std::io::BufReader::new(tty).read_line(&mut line).is_err() {
return false;
}
matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
/// Launch `omnidev refresh` fully detached so shell startup never waits on it.
fn spawn_detached_refresh() {
let Ok(exe) = std::env::current_exe() else {
return;
};
let _ = Command::new(exe)
.arg("refresh")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
+56
View File
@@ -0,0 +1,56 @@
//! Watches the backend source tree and asks the supervisor to reload on
//! Python changes. Frontend files are deliberately not watched — Vite HMR
//! handles those.
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result};
use notify::RecursiveMode;
use notify_debouncer_full::new_debouncer;
use tokio::sync::mpsc;
use crate::supervisor::Cmd;
/// Start watching `omnigent_dir` for `*.py` changes. Coalesced bursts become a
/// single `Cmd::Reload(n)` on `cmd_tx`. The returned debouncer must be kept
/// alive for the watch to persist.
pub fn spawn(
omnigent_dir: &Path,
cmd_tx: mpsc::UnboundedSender<Cmd>,
) -> Result<impl Send + 'static> {
// The debouncer coalesces rapid saves; we still filter to *.py and skip
// caches so editor churn and __pycache__ writes don't trigger reloads.
let mut debouncer = new_debouncer(
Duration::from_millis(500),
None,
move |result: notify_debouncer_full::DebounceEventResult| {
let Ok(events) = result else { return };
let mut changed = 0usize;
for event in &events {
for path in &event.paths {
if is_relevant(path) {
changed += 1;
}
}
}
if changed > 0 {
let _ = cmd_tx.send(Cmd::Reload(changed));
}
},
)
.context("creating file watcher")?;
debouncer
.watch(omnigent_dir, RecursiveMode::Recursive)
.with_context(|| format!("watching {}", omnigent_dir.display()))?;
Ok(debouncer)
}
fn is_relevant(path: &Path) -> bool {
if path.extension().and_then(|e| e.to_str()) != Some("py") {
return false;
}
!path.components().any(|c| c.as_os_str() == "__pycache__")
}
+151
View File
@@ -0,0 +1,151 @@
//! Exercises install-management logic without network or a real install:
//! spec building, config round-trip, and the update-availability/staleness
//! decisions.
use std::sync::{Mutex, MutexGuard};
// These modules reference each other via `crate::`, so declare the whole set at
// the test crate root. Each test target exercises only part of the included
// source, so allow dead code rather than chase per-item warnings.
#[allow(dead_code)]
#[path = "../src/install.rs"]
mod install;
#[allow(dead_code)]
#[path = "../src/paths.rs"]
mod paths;
#[allow(dead_code)]
#[path = "../src/update_check.rs"]
mod update_check;
use install::InstallConfig;
use update_check::{is_stale, update_available, CheckCache};
/// Tests here mutate process-global `XDG_*` env vars; serialize them.
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn lock_env() -> MutexGuard<'static, ()> {
ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn spec_default_has_databricks_extra_and_main() {
let c = InstallConfig::default();
assert_eq!(
c.spec(),
"omnigent[databricks] @ git+https://github.com/omnigent-ai/omnigent.git@main"
);
}
#[test]
fn spec_no_extras_is_bare_git_url() {
let c = InstallConfig {
repo: "https://github.com/omnigent-ai/omnigent.git".into(),
git_ref: "main".into(),
extras: vec![],
};
assert_eq!(
c.spec(),
"git+https://github.com/omnigent-ai/omnigent.git@main"
);
}
#[test]
fn spec_reflects_custom_ref_and_extras() {
let c = InstallConfig {
repo: "https://example.com/x.git".into(),
git_ref: "dev".into(),
extras: vec!["databricks".into(), "kubernetes".into()],
};
assert_eq!(
c.spec(),
"omnigent[databricks,kubernetes] @ git+https://example.com/x.git@dev"
);
}
#[test]
fn config_round_trips_through_disk() {
let _guard = lock_env();
let tmp = tempdir();
std::env::set_var("XDG_CONFIG_HOME", &tmp);
let c = InstallConfig {
repo: "https://github.com/omnigent-ai/omnigent.git".into(),
git_ref: "main".into(),
extras: vec!["databricks".into()],
};
c.save().unwrap();
let loaded = InstallConfig::load().unwrap().expect("config present");
assert_eq!(c, loaded);
std::env::remove_var("XDG_CONFIG_HOME");
}
#[test]
fn missing_config_loads_as_none() {
let _guard = lock_env();
let tmp = tempdir();
std::env::set_var("XDG_CONFIG_HOME", &tmp);
assert!(InstallConfig::load().unwrap().is_none());
std::env::remove_var("XDG_CONFIG_HOME");
}
#[test]
fn update_available_logic() {
let cache = CheckCache {
remote_sha: Some("bbbb".into()),
..Default::default()
};
// Remote differs from installed and wasn't prompted → available.
assert!(update_available(&cache, Some("aaaa")));
// Installed already matches remote → not available.
assert!(!update_available(&cache, Some("bbbb")));
// No remote sha known → not available.
assert!(!update_available(&CheckCache::default(), Some("aaaa")));
// Declining a commit (last_prompted_sha == remote) suppresses it.
let declined = CheckCache {
remote_sha: Some("bbbb".into()),
last_prompted_sha: Some("bbbb".into()),
..Default::default()
};
assert!(!update_available(&declined, Some("aaaa")));
}
#[test]
fn staleness_window() {
let now = 1_000_000u64;
let day = 24 * 60 * 60;
let fresh = CheckCache {
last_checked: now - 10,
..Default::default()
};
assert!(!is_stale(&fresh, now));
let old = CheckCache {
last_checked: now - day - 1,
..Default::default()
};
assert!(is_stale(&old, now));
// Never checked (last_checked == 0) → stale.
assert!(is_stale(&CheckCache::default(), now));
}
/// Minimal unique temp dir without pulling a dev-dependency.
fn tempdir() -> std::path::PathBuf {
let base = std::env::temp_dir();
let unique = format!(
"omnidev-mgmt-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let dir = base.join(unique);
std::fs::create_dir_all(&dir).unwrap();
dir
}
+244
View File
@@ -0,0 +1,244 @@
//! Exercises the non-TUI setup path: repo detection, pod dir tree, ports.
use std::fs;
use std::sync::{Mutex, MutexGuard};
// The crate is a binary, so pull in the modules under test directly. Each test
// target uses only part of the included source, so allow dead code.
#[allow(dead_code)]
#[path = "../src/lock.rs"]
mod lock;
#[allow(dead_code)]
#[path = "../src/paths.rs"]
mod paths;
#[allow(dead_code)]
#[path = "../src/pod.rs"]
mod pod;
#[allow(dead_code)]
#[path = "../src/ports.rs"]
mod ports;
use pod::Pod;
use ports::Ports;
/// A fake checkout (.git + omnigent/ + web/) is recognized as a root, and a
/// nested subdir resolves up to it.
#[test]
fn finds_repo_root_from_subdir() {
let tmp = tempdir();
fs::create_dir_all(tmp.join(".git")).unwrap();
fs::create_dir_all(tmp.join("omnigent/server")).unwrap();
fs::create_dir_all(tmp.join("web/src")).unwrap();
let root = paths::find_repo_root(&tmp.join("omnigent/server")).unwrap();
assert_eq!(root, tmp.canonicalize().unwrap());
}
/// A VCS root without omnigent/+web/ is rejected.
#[test]
fn rejects_non_omnigent_project() {
let tmp = tempdir();
fs::create_dir_all(tmp.join(".git")).unwrap();
assert!(paths::find_repo_root(&tmp).is_err());
}
/// Two different repo paths get distinct pod dirs; the same path is stable.
#[test]
fn pod_dir_is_per_repo_and_stable() {
let a1 = paths::default_pod_dir(std::path::Path::new("/repos/one")).unwrap();
let a2 = paths::default_pod_dir(std::path::Path::new("/repos/one")).unwrap();
let b = paths::default_pod_dir(std::path::Path::new("/repos/two")).unwrap();
assert_eq!(a1, a2);
assert_ne!(a1, b);
}
/// npm install is needed when node_modules is missing, and when a manifest is
/// newer than it; not needed when node_modules is up to date.
#[test]
fn needs_npm_install_tracks_manifests() {
let repo = tempdir();
let web = repo.join("web");
fs::create_dir_all(&web).unwrap();
fs::write(web.join("package.json"), "{}").unwrap();
let pod = Pod {
repo_root: repo.clone(),
dir: repo.join("pod"),
ports: Ports {
server: 6767,
vite: 5173,
},
vite_host: "127.0.0.1".into(),
trusted_origins: Vec::new(),
};
// No node_modules yet → install needed.
assert!(pod.needs_npm_install());
// Fresh node_modules created after the manifest → up to date.
fs::create_dir_all(web.join("node_modules")).unwrap();
assert!(!pod.needs_npm_install());
// A manifest touched after node_modules → stale, install needed.
// (Sleep briefly so the mtime is observably newer on coarse filesystems.)
std::thread::sleep(std::time::Duration::from_millis(10));
fs::write(web.join("package-lock.json"), "{}").unwrap();
assert!(pod.needs_npm_install());
}
/// Ports probe to bindable values and persist/reuse across calls.
#[test]
fn ports_resolve_and_persist() {
let tmp = tempdir();
let p1 = Ports::resolve(&tmp, None, None).unwrap();
assert_ne!(p1.server, p1.vite);
assert!(tmp.join("pod.toml").is_file());
// A second resolve reuses the persisted pair (both still free).
let p2 = Ports::resolve(&tmp, None, None).unwrap();
assert_eq!(p1.server, p2.server);
assert_eq!(p1.vite, p2.vite);
// Explicit overrides win.
let p3 = Ports::resolve(&tmp, Some(19191), Some(19292)).unwrap();
assert_eq!(p3.server, 19191);
assert_eq!(p3.vite, 19292);
}
/// Two sibling pods under the same cache root never collide, even before their
/// processes have bound anything — the second reads the first's pod.toml.
#[test]
fn sibling_pods_get_distinct_ports() {
let root = tempdir();
let pod_a = root.join("repo-aaaa");
let pod_b = root.join("repo-bbbb");
fs::create_dir_all(&pod_a).unwrap();
fs::create_dir_all(&pod_b).unwrap();
// Pod A resolves and persists first (no process is ever spawned).
let a = Ports::resolve(&pod_a, None, None).unwrap();
// Pod B must avoid A's ports purely from A's persisted claim.
let b = Ports::resolve(&pod_b, None, None).unwrap();
assert_ne!(a.server, b.server);
assert_ne!(a.vite, b.vite);
assert_ne!(a.server, b.vite);
assert_ne!(a.vite, b.server);
}
/// A pod admits one holder; a second acquire fails until the first is dropped.
#[test]
fn pod_lock_is_exclusive() {
let pod = tempdir();
let held = lock::acquire(&pod).expect("first acquire succeeds");
assert!(
lock::acquire(&pod).is_err(),
"second acquire must fail while the first is held"
);
drop(held);
lock::acquire(&pod).expect("acquire succeeds again after release");
}
const ALLOWED_ORIGINS_ENV: &str = "OMNIGENT_WS_ALLOWED_ORIGINS";
/// Tests that read/write the process-global allowlist env var; serialize them.
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn lock_env() -> MutexGuard<'static, ()> {
ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// Run `body` with `OMNIGENT_WS_ALLOWED_ORIGINS` set to `value` (or unset when
/// `None`), restoring the prior value afterward so tests don't leak env state.
fn with_allowlist_env(value: Option<&str>, body: impl FnOnce()) {
let _guard = lock_env();
let prev = std::env::var(ALLOWED_ORIGINS_ENV).ok();
match value {
Some(v) => std::env::set_var(ALLOWED_ORIGINS_ENV, v),
None => std::env::remove_var(ALLOWED_ORIGINS_ENV),
}
body();
match prev {
Some(v) => std::env::set_var(ALLOWED_ORIGINS_ENV, v),
None => std::env::remove_var(ALLOWED_ORIGINS_ENV),
}
}
fn pod_with_trusted(trusted: Vec<String>) -> Pod {
Pod {
repo_root: std::path::PathBuf::from("/repo"),
dir: std::path::PathBuf::from("/pod"),
ports: Ports {
server: 6767,
vite: 5173,
},
vite_host: "0.0.0.0".into(),
trusted_origins: trusted,
}
}
fn allowlist_from_env(pod: &Pod) -> Option<String> {
pod.env()
.into_iter()
.find(|(k, _)| k == ALLOWED_ORIGINS_ENV)
.map(|(_, v)| v)
}
/// With no trusted origins, the pod leaves the allowlist var untouched — even
/// when the developer's shell already exports one (it passes through inherited).
#[test]
fn no_trusted_origins_does_not_set_allowlist() {
with_allowlist_env(Some("https://dev.example.com"), || {
let pod = pod_with_trusted(Vec::new());
assert_eq!(allowlist_from_env(&pod), None);
});
}
/// Trusted origins with no inherited value produce exactly those origins.
#[test]
fn trusted_origins_populate_allowlist() {
with_allowlist_env(None, || {
let pod = pod_with_trusted(vec!["http://192.168.1.42:5173".into()]);
assert_eq!(
allowlist_from_env(&pod).as_deref(),
Some("http://192.168.1.42:5173")
);
});
}
/// A developer's inherited allowlist is preserved and the LAN origins are
/// appended (order-preserving, deduped) rather than clobbered.
#[test]
fn trusted_origins_merge_with_inherited_allowlist() {
with_allowlist_env(
Some("https://dev.example.com, http://192.168.1.42:5173"),
|| {
let pod = pod_with_trusted(vec![
"http://192.168.1.42:5173".into(), // already inherited → not duplicated
"http://10.0.0.9:5173".into(),
]);
assert_eq!(
allowlist_from_env(&pod).as_deref(),
Some("https://dev.example.com,http://192.168.1.42:5173,http://10.0.0.9:5173")
);
},
);
}
/// Minimal unique temp dir without pulling a dev-dependency.
fn tempdir() -> std::path::PathBuf {
let base = std::env::temp_dir();
let unique = format!(
"omnidev-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let dir = base.join(unique);
std::fs::create_dir_all(&dir).unwrap();
dir
}
+164
View File
@@ -0,0 +1,164 @@
# Queue + steer design
Client-side message queue with edit / delete / steer / reorder, for both SDK and
native harnesses.
## 1. Motivation
Today every message is **POSTed the moment the user hits send** — including
follow-ups typed while the agent is still working — and rendered immediately as an
optimistic bubble. The runner buffers a mid-turn message behind the active turn
and delivers it later, but the UI has already committed it. Problems:
- **No edit / delete / reorder.** Once POSTed the message is server-owned, so the
user can't take back or fix a follow-up they queued in a hurry.
- **No queued-vs-sent visibility.** A follow-up sent mid-turn looks identical to a
normal send — the user can't tell it's waiting behind the active turn, or when
it will be picked up.
- **Silent cross-harness inconsistency.** The *same* action — "send a follow-up
while the agent is working" — behaves differently per harness (mid-turn steer
for live-queue SDKs, next-turn for everyone else) with no signal telling the
user which they'll get.
The redesign fixes all three by holding the message in a **client-side queue
before it is POSTed**: the user can edit / delete / reorder while it waits, sees
it explicitly as "queued", and controls when it's sent (auto-flush on idle, or
steer now).
## 2. Proposal
Move the queue **client-side**. The strip becomes a pre-POST draft buffer; a
message is only sent to the server when it's flushed or steered.
```
type → client queue "⏱ Queued" (NOT posted) → flush/steer → POST → bubble
(strip = "not yet sent, still editable"; bubble = "sent, in flight")
```
### Queue behavior
- **Show as queued** when the agent is **not idle** (`sessionStatus` busy) — same
signal for SDK and native.
- **Auto-flush head on idle (FIFO):** when the agent goes idle, send the head of
the queue as the next turn. Type-ahead "just works" without any click.
- Persist the queue in `localStorage` (keyed by session) so it survives a hard
refresh. (Trade-off: no cross-device sync — acceptable for unsent drafts.)
### Per-message actions
| Action | Behavior |
|--------|----------|
| **Edit** | pull the message back into the composer, purely client-side; persists across navigation/refresh |
| **Delete** | drop the message from the queue |
| **Steer** | POST it now (jump the queue) — deliver mid-turn where the harness supports it |
| **Reorder** | client-side drag (grip handle) to reorder the queue within a conversation |
### Promote-to-bubble rule
Promote a message from the strip into a normal chat bubble **as soon as it is
POSTed** (on flush or steer) — *not* when the agent consumes it. Once it's sent
there's no longer anything to edit / delete / steer / reorder, so the strip has
no reason to hold it.
The gap between (a) sent to server and (b) consumed by the agent becomes an
**implementation detail** the user need not see — because the strip no longer
represents server state, only the still-editable client buffer. This removes the
consume-timing dependency entirely.
### What "steer" means per harness
Steer always POSTs immediately; how it lands depends on the harness:
Steer always POSTs immediately (client-side, no runner change); how it lands
depends on the harness. The steer button is shown for **all** native sessions —
the runner delivers uniformly (POST → buffer → drain → hand to app, all natives'
`run_turn` return right after delivery), and the app decides what to do with a
message that arrives mid-response:
| Harness | Steer delivery | Mid-turn? |
|---------|----------------|-----------|
| claude-sdk / codex-sdk / pi-sdk | runner **live injection** (`_live_response_id` gate) | ✅ deterministic |
| cursor-sdk / copilot-sdk | buffer & drain | ❌ next turn |
| **codex-native** | explicit **`turn/steer`** RPC when a turn is active | ✅ deterministic *(verified)* |
| **claude-native** | `send-keys` into the **live pane**; the TUI folds the paste into the response | ✅ verified (best-effort timing) |
| cursor-native / hermes-native | `send-keys` paste into the **live pane** (`supports_enqueue=True`) | ⚠️ app-defined — mechanism confirmed in code, **not yet verified live** |
| pi-native | queued to the **resident extension** (`supports_enqueue=True`) | ⚠️ app-defined — mechanism confirmed in code, not yet verified live |
| opencode-native | HTTP prompt (`supports_enqueue=True`); the native server has **no live-steer endpoint** → admitted as a new prompt, promoted by the server's own queue at turn end | ❌ next turn (code-confirmed) |
| qwen / goose / kimi / kiro / antigravity -native | paste / file / RPC into the app (`supports_enqueue=True`) | ⚠️ app-defined — not yet verified live |
> **TODO (live verification):** every native harness above reports
> `supports_live_message_queue = True` and its delivery mechanism is confirmed
> in code (see the enqueue path per harness), but whether the vendor app folds
> the steered message in **mid-response** vs. at the **next turn** is confirmed
> against a *live* runner only for claude-native + codex-native. Run a live
> steer per harness to upgrade the ⚠️ rows. opencode-native is settled: its app
> server exposes no live-steer endpoint, so the steered message is always
> promoted at the next turn boundary.
**No runner change is required for native steer** — every native `run_turn`
returns right after delivering the input (decoupled from the response), so the
drain fires the next message quickly and it reaches the app while the prior
response is likely still running; the app does its own steering. Frame the UX
honestly: *"send now; the agent folds it into current work if it can"* — which is
exactly how native type-ahead already feels. Do **not** promise deterministic
mid-turn for the unverified natives.
**Steer is not interrupt.** In every case above, steer *does not cancel* the
running turn — the message is folded in at the agent's next natural breakpoint
(after the current tool/step completes), the same feel as steering native Claude
by typing while it works. For SDK, `enqueue_session_message` adds the message to
the running session's queue; the SDK surfaces it at its next turn-boundary — no
teardown. This is distinct from the **Interrupt** button, which really does
cancel the turn (`turn.cancel()`).
### Edges to handle
| Edge | Rule |
|------|------|
| POST fails after promote | revert the bubble to the queue (or error-badge it) |
| Agent goes idle mid-edit | editing pins the message out of auto-flush until re-committed |
| Native mirror-back | consume/mirror still needed as a **reconcile** signal (id-match the optimistic bubble to the real transcript item) so native round-trips don't double-render |
## 3. Appendix — lifecycle & topology
### Component topology
```
┌──────────┐ HTTPS+SSE ┌──────────────┐ HTTP ┌──────────┐ HTTP/UNIX socket ┌─────────────────┐
│ CLIENT │◄───────────►│ AP SERVER │◄──────►│ RUNNER │◄──────────────────►│ HARNESS SUBPROC │
│ (browser)│ │ persist+relay│ │ buffer + │ (1 per conv) │ EXECUTOR=agent │
└──────────┘ └──────────────┘ │ schedule │ │ SDK: in-process │
└──────────┘ │ native: →app ───┼─► tmux / RPC
└─────────────────┘
```
The agent runs **inside the harness subprocess** (SDK loop) or is **bridged out**
of it to a real app (native). It does **not** live in the runner process.
### Busy/idle signal (drives the queue)
| Harness | "running" from | "idle" from |
|---------|----------------|-------------|
| SDK | `response.created``_live_response_id` set | `response.completed` / stream-end |
| native | `UserPromptSubmit` hook | `Stop` / `StopFailure` hook (relayed by the transcript forwarder) |
Both surface to the client as the same `sessionStatus` field, seeded from the
snapshot on bind (correct after refresh, across tabs).
### Live-injection gate (SDK steer)
```python
_can_forward = (
not _native # native uses paste / turn-steer, not this path
and not _awaiting_approval # don't steer a turn parked on a human gate
and conversation_id in _live_response_id # a response is actually streaming
)
```
### Native decoupling (why paste-steer works)
Native `run_turn` returns as soon as `send-keys` finishes pasting (not when the
agent finishes). `_active_turns` clears immediately, so the buffer drains the
next message quickly and it pastes into the still-live pane — the native app then
decides to steer it. `_native_pane_status` is the reliable liveness signal for a
long autonomous native turn (since `_active_turns` clears early).
+203 -103
View File
@@ -6,6 +6,13 @@ available", "is steering possible", "does policy DENY actually block a call" —
instead of a human hand-maintaining a spreadsheet and hoping it still reflects
reality.
> **Status:** shipped and in use. The bench on `main` has three transport
> drivers, six P0 probes, three report-only P1 probes, automatic live/offline
> selection, and a capability-derived matrix that has already caught and
> corrected real declaration drift. See
> [Current state](#current-state-shipped) for what is live vs. still open. The
> sections before it describe the design and the decisions behind it.
## Motivation
We maintain a capability matrix by hand (the native + SDK support
@@ -59,6 +66,13 @@ This constraint is what shapes the coupling decision below. It is *not* a limit
on what the bench can probe: the probes are harness-agnostic. It is only a limit
on how a harness gets *discovered*.
> **Update since this was written:** entry-point plugin discovery now exists —
> `harness_capabilities()` merges contributions from the
> `omnigent.community.harness` entry-point group, and the bench derives
> everything from it. So the bench side of option B is realized: a plugin's
> harness flows in with no bench edit. The remaining hardcoded seam is *not*
> here — it is the server's native-agent seeding (see "Plugin seamlessness").
## Decision: option B (registry-indexed now, profile-driven from day one)
Two coupling options were considered:
@@ -112,48 +126,42 @@ list" to "discover"; probes, profiles, and reports are untouched.
## Architecture
Three layers plus a report step.
The implementation has three layers plus reporting:
```
tests/harness_bench/
profile.py # BenchProfile: per-harness self-declared facts
manifest.py # registry of official BenchProfiles (the spreadsheet as data)
verdict.py # Verdict enum, ProbeResult, priority (P0/P1)
transports/ # transport drivers keyed by class
_base.py # TransportDriver: launch/session/turn against a harness
sdk_inproc.py # in-proc HTTP (reuses existing e2e server helpers)
tmux_tui.py # (phase 2)
app_server.py # (phase 2)
http_sse.py # (phase 2)
probes/ # one module per dimension
_base.py # CapabilityProbe: name, priority, applies_to, declared(), run()
basic_turn.py
streaming.py
tool_calling.py # incl. "connects to Omnigent MCP"
interrupt.py
policy_deny.py
model_override.py
... # (phase 2: steering, live_queue, resume_fork, elicitation,
# reasoning, images, cost, compaction)
bench.py # driver: iterate probes x harnesses -> matrix
report.py # render Markdown + JSON, with a DRIFT column
test_bench.py # pytest wrapper (parametrized) for CI
profile.py # BenchProfile and profile-name resolution
manifest.py # official profiles derived from capabilities + e2e metadata
verdict.py # verdict vocabulary, priority, and drift reconciliation
transport.py # semantic Driver protocol and transport resolution
driver.py # sdk-inproc driver + shared TurnResult/usage helpers
full_server.py # shared server/runner lifecycle and registration
full_server_driver.py # full-server driver and session polling
native_tui_driver.py # native vendor CLI + host-daemon/tmux driver
session_items.py # shared session-item envelope parsing
runtime_env.py # config/credential resolution matching `omni run`
probes/ # one module per capability dimension
events.py # structured progress events and plain sink
richreport.py # optional live Rich matrix
bench.py # orchestration, concurrency, and shared-server wiring
report.py # terminal, Markdown, and JSON rendering
```
- **Layer 0 — Profile / manifest.** The spreadsheet, as data. Source of truth
for the static columns and the *expected* verdicts for behavioral ones.
- **Layer 1 — Offline conformance** (no network, always in CI). Harness
registers, `create_app()` builds, required routes exist, `Executor` flags are
internally consistent, a `BenchProfile` exists. Fast, catches structural
regressions.
- **Layer 2Live probes** (gated on CLI + creds; reuses
`skip_if_harness_cli_missing`). Runs the behavioral table against a live
server, exactly like the existing e2e tests
(`/v1/sessions` + `send_user_message_to_session` +
`poll_session_until_terminal` + `final_assistant_text`).
- **Report.** `python -m tests.harness_bench --harness codex` prints one
harness's matrix; no filter regenerates the whole sheet with a `DRIFT` column
diffing declared vs observed.
Reusable configuration and runtime primitives live in production modules such
as `omnigent.config`, `find_free_port`, and the harness registry rather than
being reimplemented under tests.
- **Layer 0 — Profile / manifest.** Static facts and declared verdicts are
derived from `harness_capabilities()` plus the existing e2e harness metadata.
- **Layer 1Offline conformance.** No network or credentials. It validates
registration, profile shape, capability derivation, transport resolution,
rendering, and orchestration behavior in normal CI.
- **Layer 2 — Live probes.** Drivers execute behavioral probes through the
wrap boundary or the real server/runner session API. Missing credentials,
vendor binaries, or vendor login produce capability-neutral skips.
- **Report.** The CLI renders the declared matrix offline or reconciles live
observations into terminal, Markdown, and JSON reports. `DRIFT` produces a
non-zero exit status.
### Build on `HarnessProbe`, don't reinvent it
@@ -193,20 +201,18 @@ Validated for presence and shape only: `Owner`, `Transport`, `Implementation`,
| Dimension | How the probe proves it |
|---|---|
| Basic turn (prereq) | ask model to reply with `<marker>`, assert marker in final text |
| Connects to Omnigent MCP | expose an Omnigent tool, ask model to call it, assert `ToolCallRequest` dispatched through the relay |
| Streaming | count `TextChunk` events: >1 delta = `deltas`, single blob = `complete-only` |
| Model override | launch with a chosen model, assert routing (gateway request / `TurnComplete` usage model); cross-family reject verified via `model_family_mismatch` |
| Policy: DENY | set DENY on a tool, ask model to call it, assert the call is blocked + surfaced |
| Policy: ASK -> Elicitation | set ASK, assert an elicitation event is emitted upstream (web-surfaceable) |
| Interrupt | start a long turn, call `interrupt_session`, assert it stops promptly |
| Live queue (concurrent) | `enqueue_session_message` mid-turn, assert accepted (not rejected) |
| Tool-boundary steer | inject steering text at a tool boundary, assert the next turn reflects it |
| Resume/fork from transcript | run a convo, resume in a fresh session, assert prior context present; fork = branch diverges |
| Compaction | assert `CompactionComplete` surfaced when triggered |
| Reasoning | reasoning-heavy prompt, assert `ReasoningChunk` emitted |
| Images | send an image, assert the model describes it |
| Cost tracking | assert `TurnComplete` carries usage / cost |
| Basic turn (P0 prerequisite) | complete a marker-echo turn and require assistant text |
| Streaming (P0) | count output-text deltas; repeated single-delta output is `PARTIAL` |
| Tool calling (P0) | provoke the transport's tool mechanism and require a surfaced call |
| Policy DENY (P0) | apply a tool-call deny and require a blocked-call signal |
| Policy ALLOW (P1) | attach an explicit allow and require a non-blocked tool output; native hooks expose no positive ALLOW event |
| Policy ASK (P1) | apply ask and require an elicitation/approval request |
| Model override (P0) | validate the requested harness/model pair and complete a turn |
| Cost tracking (P1) | read priced cost or token usage from the turn/session |
| Interrupt (P0) | interrupt a long turn and require cancellation or early termination |
Planned dimensions are steering, live queue, resume/fork, reasoning, images,
and compaction.
Every behavioral probe also reads the corresponding declared flag and returns
`DRIFT` when observed disagrees with declared.
@@ -234,27 +240,56 @@ class StreamingProbe(CapabilityProbe):
## Transport drivers: the real ceiling on "all dimensions"
Behavioral probes run through a **transport driver** keyed by transport class
(SDK in-proc HTTP, tmux TUI, app-server, HTTP/SSE). A harness that reuses an
existing transport class is fully covered. A harness that invents a novel
transport degrades its transport-dependent probes to `SKIPPED`/`UNKNOWN` until a
driver for that class exists — but model-agnostic dimensions (streaming, MCP,
policy, cost) stay covered regardless.
Behavioral probes call semantic driver methods such as `run_basic_turn`,
`run_tool_turn`, `run_policy_turn`, and `run_interrupt_turn`. Drivers own the
transport-specific mechanism; probes interpret a common `TurnResult`.
This is why "run the bench, see all verdicts, zero code" is true *for any
harness reusing a known transport class*, and honest about the one case where it
is not.
Three drivers exist:
## Phasing
- `full-server` is the SDK-family default. It drives a real server and runner,
uses a server-dispatched builtin for tool probes, and observes fixed
ALLOW/ASK/DENY policies.
- `native-tui` drives a resident vendor CLI in a runner-owned tmux pane through
the server session API. It observes vendor tool calls and tool-call DENY via
the native policy hook. ALLOW/ASK are not yet implemented.
- `sdk-inproc` drives the harness wrap directly. It is selected by `--fast` and
provides cheaper wrap-level coverage, but no server-side policy surface.
- **MVP (P0).** Layer 0 profile/manifest + Layer 1 offline conformance + Layer 2
P0 probes (basic turn, streaming, MCP/tool-calling, interrupt, policy DENY,
model override) + the **SDK in-proc transport driver** + report with `DRIFT`
column. Wire the SDK harnesses already in `HARNESS_PROBES` (claude-sdk, codex,
pi, openai-agents).
- **P1.** Steering, live-queue, resume/fork, elicitation ASK, reasoning, images,
cost, compaction; the tmux / app-server / HTTP-SSE transport drivers; the
remaining SDK + all native harness profiles.
A `SKIPPED` verdict therefore means the behavior was not measurable in that
transport or environment, not that the harness lacks the capability. A novel
transport class still requires a driver, but harnesses reusing one of these
families flow through the existing probes without per-harness probe code.
## Current state (shipped)
The bench on `main` includes:
- **Six P0 probes:** Basic turn, Streaming, Tool calling, Policy DENY, Model
override, and Interrupt.
- **Three P1 probes:** Policy ALLOW, Policy ASK, and Cost tracking. P1 verdicts
are report-only and do not gate the same way as P0 declarations.
- **Three transport drivers:** `full-server`, `native-tui`, and `sdk-inproc`,
selected by harness family with `--transport` and `--fast` overrides.
- **Automatic live selection:** without an explicit mode, the CLI runs live
when credentials are resolvable and otherwise renders the declared matrix.
`--live` and `--no-live` force either mode. Credentials are derived like
`omni run`; `--profile` is only an override.
- **Concurrent execution and shared infrastructure:** `--jobs` runs harnesses
concurrently while preserving report order, and full-server harnesses share
one server/runner pair within a run.
- **Structured progress and reports:** plain or Rich live progress plus terminal,
Markdown, JSON, and optional report-file output.
- **Capability-derived registration:** official SDK and native profiles derive
from `harness_capabilities()` and existing e2e metadata. Session-item parsing,
config loading, free-port selection, and polling helpers are shared rather
than duplicated.
### Not yet wired
- Registry-driven server seeding for community native UI agents.
- Steering, live queue, resume/fork, reasoning, images, and compaction probes.
- Automatic provisioning of vendor login/provider configuration for native
harnesses; unavailable environments skip cleanly.
## CI integration
@@ -266,37 +301,29 @@ is not.
## Running the bench and reading the result
```
# Offline: the declared matrix, no creds, every harness. Fast.
python -m tests.harness_bench
# Declared matrix only, with no credentials.
python -m tests.harness_bench --no-live
# Live: probe one harness against a gateway profile.
python -m tests.harness_bench --harness codex-native --profile oss
# Auto-live when configured or ambient credentials are available.
python -m tests.harness_bench --harness codex
# Live: probe every official harness (SDK + native) sequentially.
python -m tests.harness_bench --profile oss
# Force a named profile and probe several harnesses concurrently.
python -m tests.harness_bench --profile oss --jobs 4 --rich
# A community harness that ships its own BenchProfile.
python -m tests.harness_bench --harness mypkg.harness:PROFILE --profile oss
python -m tests.harness_bench --harness mypkg.harness:PROFILE --live
```
**You do not need to live-probe every harness on every host — and you cannot.**
Each native harness needs its own vendor CLI logged in (a login the bench
cannot provision), so no single host has them all. The two layers split the
work:
Without `--live` or `--no-live`, resolvable credentials select live mode and
missing credentials select the offline declared matrix. Native harnesses also
need their vendor CLI installed and logged in; the bench cannot provision those
accounts, so unavailable harnesses skip without aborting the run.
- **Offline conformance** already covers every harness in CI — registration,
the declared matrix, capability derivation. No host access needed.
- **Live probes** only answer "does observed behavior match the declaration?"
You get value from live-probing a harness where the declaration is unverified
or might be wrong — not from chasing 100% coverage on one box.
Run the full set on whatever host you have (`--profile oss`); harnesses whose
vendor CLI is absent or logged out **skip cleanly** (they do not fail or abort
the run). Read two signals only: any `!!` DRIFT, and any harness you *can* run
that shows an unexpected `✗` / `·`. A single live run is a spot-check, not a
gate — live probes are non-deterministic (model behavior, timing), so re-run
before treating one `·`/timeout as a regression. Drift coverage is cumulative:
each host that has harness X logged in contributes a live check for X.
Offline conformance covers every registered harness in CI. Live runs are
spot-checks of observed behavior and can vary with model behavior and timing;
re-run an isolated timeout or skip before treating it as a regression. The
signals that matter most are `DRIFT` and repeatable unexpected
`UNSUPPORTED`/`PARTIAL` verdicts on a runnable harness.
## Streaming is a binary declared capability
@@ -306,16 +333,89 @@ a harness either forwards token-level deltas (`SUPPORTED`) or it does not
returns it for the ambiguous coalesced-single-delta case against a `SUPPORTED`
declaration. It is **never a declared value**. Declaring a non-streaming
harness as `PARTIAL` drifts against reality, because the probe reports zero
deltas as `UNSUPPORTED`, not `PARTIAL`. This bit the transcript-mirror natives
(kiro/goose/qwen/hermes/cursor/kimi/pi), which deliver each complete assistant
message rather than streaming deltas: they declare `streaming=False`
`UNSUPPORTED`, matching what the probe observes.
deltas as `UNSUPPORTED`, not `PARTIAL`.
**Declare `streaming=False` only from a live observation of 0 deltas** — a
static "the forwarder posts no delta" grep is *not* sufficient. That grep once
flipped seven natives to `False` in one batch; a live run then showed
pi-native streams (7 deltas) despite having no delta-posting forwarder, so the
flip was reverted. Only three natives are declared non-streaming today, each
live-verified at 0 deltas: **kiro-native, cursor-native, qwen-native**. The
rest default to `streaming=True` (the honest default: if one turns out not to
stream, the bench flags a real drift on the next run, rather than a false
`False` that silently drifts the moment the harness *does* stream).
## Which transport exercises which dimension
| Dimension | `sdk-inproc` (`--fast`) | `full-server` (SDK default) | `native-tui` |
|---|---|---|---|
| Basic turn, Streaming, Model override, Interrupt | Wrap-level observation | End-to-end server/runner observation | End-to-end server/runner/vendor observation |
| Tool calling | Request-level wrap tool | Server-dispatched builtin | Vendor tool mirrored into session items |
| Policy DENY | Not observable | Fixed policy blocks the builtin | Session CEL policy triggers the native policy hook |
| Policy ALLOW / ASK | Not observable | Fixed policy; ASK observes and resolves an elicitation | Temporary session CEL policy; ASK observes and resolves an elicitation |
| Cost tracking | Completed-response usage when forwarded | Session snapshot usage/cost | Session snapshot when the vendor forwards usage |
`full-server` remains the SDK default because it covers the deployed server
path and all three policy actions. `--fast` trades that policy coverage for
lower startup cost. `native-tui` now has real Tool calling and all three policy
action probes through the native hook path.
## Plugin seamlessness: where it is and isn't
The original goal (option B) was that a *community* harness ships a
`BenchProfile` and runs with `--harness <name>` and no bench edits. For the
**bench itself, that holds**: profile resolution, capability derivation, and
`native_vendor()` all read `harness_capabilities()`, which discovers community
plugins via entry points. A plugged-in harness needs zero bench code to be
recognized.
The seam is **one level down, in the omnigent server**. A native harness is
only drivable once the server has seeded a built-in `<harness>-native-ui`
agent, and that seeding is a **hardcoded list** in
`server/app.py:_ensure_default_agents` — one `_ensure_default_<harness>_agent()`
call per harness. goose-native and hermes-native were in the capability
registry but omitted from that list, so the bench (correctly) reported them
`not auto-registered on the server` until the seeders were added.
So: **the bench is plugin-seamless; the server's native-agent seeding is not,
and the bench inherits that seam.** A community native plugin today resolves in
the bench, then fails at registration because nothing seeds its UI agent. The
clean fix is to make `_ensure_default_agents` iterate `native_agents()` from
the registry (which already includes plugins) instead of a hardcoded call list
— then native harnesses and plugins register automatically. This is the highest
-leverage remaining item: it is the difference between "the bench is plugin-
ready" and "a plugged-in native harness works end to end".
## The self-enforcing table in practice (drift case studies)
`reconcile()` turns a false capability declaration into a `DRIFT`. This is not
theoretical — the bench caught several real declaration errors this way, each
resolved by correcting the *source* (the capability model), not the bench:
- **kiro-native / streaming.** Declared `SUPPORTED`, observed 0 deltas
(`!!✓>✗`). kiro mirrors each complete assistant message rather than streaming
tokens. Corrected to `streaming=False`.
- **pi-native / streaming (a fixed over-correction).** A static grep had flipped
pi to `False`; a live run showed it streams 7 deltas (`!!✗>✓`) despite having
no delta-posting forwarder. Reverted to `True`. This is why the rule is
"declare `False` only from a live 0-delta observation" — the grep lied.
- **cursor-native / streaming + provisioning.** cursor could not provision at
all until the `lazy_chat` fix (its `external_session_id` is created by the
first message, not at launch, so gating on it pre-turn deadlocked). Once
runnable, it observed 0 deltas → `streaming=False`.
- **qwen-native / streaming.** Observed 0 deltas → `streaming=False`.
The pattern each time: the bench detects the mismatch, a live probe pins which
side is wrong, and the capability model is corrected — not the bench massaged to
agree with it.
## Open items
- Exact `BenchProfile` field set and whether it subsumes `HarnessProbe` or wraps
it.
- Whether the manifest fully retires the spreadsheet, or the bench diffs against
an exported CSV so the sheet stays canonical during transition.
- Native transport drivers are the larger half of the work; sequence them by
which harnesses matter most for the matrix.
- **Registry-driven native-agent seeding** — replace the hardcoded server
seeding list with registry iteration so community native harnesses work end
to end after plugin installation.
- **Per-harness native provisioning** — some vendors require login or provider
configuration that the bench deliberately cannot create. Improve diagnostics
where possible while retaining clean skips.
- **Additional dimensions** — steering, live queue, resume/fork, reasoning,
images, and compaction.
+10 -1
View File
@@ -273,9 +273,10 @@ os_env:
sandbox:
type: none
# Generic shell terminal for long-running processes (dev servers, watchers, log
# Generic shell terminals for long-running processes (dev servers, watchers, log
# tails) and ad-hoc shell when sys_os_shell's blocking model doesn't fit. NOT
# for launching coding agents / sub-agents — those go through sys_session_send.
# bash and zsh are both offered; the "+ New shell" affordance picks between them.
terminals:
shell:
command: bash
@@ -285,6 +286,14 @@ terminals:
cwd: .
sandbox:
type: none
zsh:
command: zsh
allow_cwd_override: true
os_env:
type: caller_process
cwd: .
sandbox:
type: none
tools:
# Coding sub-agents — see agents/<name>/. claude_code, codex, opencode,
+65
View File
@@ -0,0 +1,65 @@
# Remy — an assistant that remembers.
#
# Remy uses Hindsight long-term memory so what you tell it in one run is
# available in every future run. Before answering it recalls what it already
# knows about you; when you share a durable fact it retains it; and it can
# reflect over everything it has stored.
#
# Memory is keyed by the agent id, so all of Remy's runs share one memory bank.
#
# Setup:
# pip install 'omnigent[memory]'
# export HINDSIGHT_API_KEY=hsk_... # https://ui.hindsight.vectorize.io
#
# Usage:
# omnigent run examples/remy
#
# Remy runs on the Claude Agent SDK harness, so configure a Claude provider
# first (e.g. `omnigent setup`, or export ANTHROPIC_API_KEY).
spec_version: 1
name: remy
description: >-
A helpful assistant with long-term memory. Remy recalls what it already knows
before answering, retains durable facts you share, and can reflect over its
memory — powered by Hindsight.
executor:
type: omnigent
config:
harness: claude-sdk
prompt: |
You are Remy, a helpful assistant with long-term memory powered by Hindsight.
Your conversation context is wiped between sessions — the ONLY way you
remember anything is by calling these tools. Acknowledging a fact in chat does
NOT save it.
Use your memory tools deliberately:
- Call `hindsight_recall` BEFORE answering anything that might depend on what
you already know about the user or past conversations.
- When the user shares a durable fact, preference, or decision — or asks you
to remember something — you MUST call `hindsight_retain`. Never say you have
saved or will remember something unless `hindsight_retain` actually ran and
returned success in this turn.
- Call `hindsight_reflect` when the user asks you to summarize or reason about
what you know overall, rather than retrieve specific facts.
Weave recalled memories into your answer naturally — don't dump raw tool
output. If recall returns nothing relevant, just answer normally.
# bank_id pins a stable, human-readable memory bank. Omit it and the bank
# defaults to the agent id (per-agent isolation) — handy, but opaque to find
# in Hindsight. A fixed name keeps Remy's memory in one easy-to-inspect bank.
tools:
builtins:
- name: hindsight_recall
api_key: ${HINDSIGHT_API_KEY}
bank_id: remy
- name: hindsight_retain
api_key: ${HINDSIGHT_API_KEY}
bank_id: remy
- name: hindsight_reflect
api_key: ${HINDSIGHT_API_KEY}
bank_id: remy
+60
View File
@@ -91,6 +91,66 @@ def default_shell_argv(command: str) -> list[str]:
return [sh, "-c", command]
#: Interactive shells we honor from ``$SHELL`` for a user terminal. Anything
#: outside this set (or a ``$SHELL`` that doesn't resolve on PATH) falls back to
#: bash for a predictable pane.
_KNOWN_INTERACTIVE_SHELLS = frozenset({"bash", "zsh", "fish", "sh", "dash", "ksh", "tcsh"})
#: Mainstream interactive shells we proactively offer as launch choices (the
#: "New shell" picker), in display order. The user's ``$SHELL`` is always
#: offered first regardless (see :func:`installed_interactive_shells`); this is
#: the set of well-known alternatives we surface beyond it.
_OFFERED_INTERACTIVE_SHELLS = ("bash", "zsh", "fish")
def default_interactive_shell() -> str:
"""
Basename of the user's login shell for an interactive terminal.
Reads ``$SHELL`` and keeps its basename when it names a known shell that
resolves on PATH; otherwise falls back to ``"bash"``. Returns a basename
(not the absolute ``$SHELL`` path) so it stays PATH-resolvable when the
terminal launches under a runner on a different host than the one that read
the env.
:returns: A shell basename such as ``"zsh"``, ``"fish"``, or ``"bash"``.
"""
if IS_WINDOWS:
# Native tmux/PTY terminals are unsupported on Windows anyway.
return "bash"
import shutil
name = os.path.basename(os.environ.get("SHELL", "")).strip()
if name in _KNOWN_INTERACTIVE_SHELLS and shutil.which(name):
return name
return "bash"
def installed_interactive_shells() -> list[str]:
"""
Ordered, deduped shell basenames to offer for a new interactive terminal.
The user's login shell (:func:`default_interactive_shell`) comes first — so
the "New shell" affordance can treat entry ``[0]`` as the click default —
followed by any mainstream alternatives (bash/zsh/fish) that resolve on
PATH. Always non-empty (the default is always present, and bash is the
ultimate fallback).
:returns: Basenames such as ``["zsh", "bash", "fish"]`` — the default first.
"""
ordered = [default_interactive_shell()]
if IS_WINDOWS:
# Native tmux/PTY terminals are unsupported on Windows anyway; the lone
# bash default from above is all we can meaningfully offer.
return ordered
import shutil
for name in _OFFERED_INTERACTIVE_SHELLS:
if name not in ordered and shutil.which(name):
ordered.append(name)
return ordered
def stable_user_id() -> str:
"""
A stable, filesystem-safe token identifying the current OS user.
+4 -12
View File
@@ -124,6 +124,7 @@ from omnigent.host.daemon_launch import (
wait_for_host_online,
wait_for_runner_online,
)
from omnigent.native_coding_agents import native_shell_terminal_spec
from omnigent.native_terminal import (
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
)
@@ -313,18 +314,9 @@ def _materialize_antigravity_agent_spec(tmpdir: Path) -> Path:
# the ``sys_terminal_*`` family to the wrapped agy (the relay's gate is
# a non-empty ``terminals:`` block on this spec). This also feeds the
# web-UI new-terminal affordance (``server/routes/sessions.py``), so it
# is not inert even independent of the relay.
"terminals": {
"shell": {
"command": "bash",
"allow_cwd_override": True,
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {"type": "none"},
},
},
},
# is not inert even independent of the relay. Its command follows the
# user's ``$SHELL`` (zsh/fish/bash).
"terminals": native_shell_terminal_spec(),
}
yaml_path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8")
return yaml_path
+39 -3
View File
@@ -272,6 +272,35 @@ def _freshest_waiting(
return same_kind
def _waiting_step_at(
steps: list[dict[str, object]],
*,
trajectory_id: str,
step_index: int,
) -> PendingInteraction | None:
"""
Return the WAITING interaction at an exact ``(trajectory_id, step_index)``.
Pins verdict delivery to the step the elicitation was surfaced for rather than
the freshest WAITING step (which could be a different gate that appeared
meanwhile). Returns ``None`` when that step is no longer WAITING (timed out or
answered), letting the caller fall back to ``_freshest_waiting`` for agy's
same-gate timeout-retry.
:param steps: Trajectory steps snapshot.
:param trajectory_id: The surfaced step's trajectory id.
:param step_index: The surfaced step's index.
:returns: The matching WAITING :class:`PendingInteraction`, or ``None``.
"""
for step in steps:
pending = pending_interaction(step)
if pending is None:
continue
if pending["trajectory_id"] == trajectory_id and pending["step_index"] == step_index:
return pending
return None
async def bridge_interaction(
cascade_id: str,
pending: PendingInteraction,
@@ -351,9 +380,16 @@ async def bridge_interaction(
)
return
# Re-read the freshest WAITING step BEFORE delivering: the captured ids
# may be stale if agy timed out + retried while the human deliberated.
fresh = _freshest_waiting(await get_steps(), kind=current["kind"])
# Re-read the steps BEFORE delivering: the captured ids may be stale if agy
# timed out + retried while the human deliberated. PIN to the step we
# surfaced if it is STILL WAITING — deliver THIS verdict to THAT gate, never
# to a different higher-index gate that appeared meanwhile (#1472 review).
# Only when our captured step is gone (timed out → ERROR) do we fall back to
# the freshest WAITING, which is agy's same-gate timeout-retry (§2.1).
steps = await get_steps()
fresh = _waiting_step_at(
steps, trajectory_id=current["trajectory_id"], step_index=current["step_index"]
) or _freshest_waiting(steps, kind=current["kind"])
if fresh is None:
_logger.warning(
"agy elicitation %s resolved but no WAITING step remains to "
+130 -13
View File
@@ -123,6 +123,18 @@ _DEFAULT_ROTATION_INTERVAL_S = 3.0
# exception, never a clean immediate return.
_STREAM_REENTRY_BACKOFF_S = 0.5
# Teardown drain passes for the interaction bridge + chained re-scan tasks. Cancelling
# a bridge stops it scheduling a re-scan and vice versa, so the chain collapses fast;
# a few extra passes give slack without risking an unbounded loop.
_INTERACTION_DRAIN_PASSES = 4
# Re-scan poll retry budget. The bridge-clear re-scan is the sole backstop on the
# healthy-stream path (agy emits no frame while parked on a deferred gate and the poll
# loop is only the failure fallback), so a single swallowed error would strand the gate
# forever. Retry a bounded number of times before giving up.
_INTERACTION_RESCAN_POLL_ATTEMPTS = 3
_INTERACTION_RESCAN_POLL_BACKOFF_S = 0.2
# POST retry policy, kept identical to the transcript forwarder's so mirrored
# items are delivered with the same transient-retry semantics. Conversation
# items persist with a random primary key and are NOT deduped server-side, so an
@@ -1030,11 +1042,25 @@ async def supervise_reader(
body_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await body_task
active = state.interaction_task
if active is not None and not active.done():
active.cancel()
with contextlib.suppress(asyncio.CancelledError):
await active
# Drain the bridge and any chained re-scan tasks. Yield once per pass so
# a normally-completed bridge's pending ``_clear_slot`` callback lands in
# ``interaction_rescans`` before the snapshot — otherwise it escapes the
# drain and runs post-teardown. Suppress all exceptions (including
# CancelledError) to avoid aborting the drain with orphaned tasks.
for _ in range(_INTERACTION_DRAIN_PASSES):
await asyncio.sleep(0)
inflight = [
pending
for pending in (state.interaction_task, *state.interaction_rescans)
if pending is not None and not pending.done()
]
if not inflight:
break
for pending in inflight:
pending.cancel()
for pending in inflight:
with contextlib.suppress(asyncio.CancelledError, Exception):
await pending
# Report how many committed steps (turns) this run mirrored for the bound
# cascade. The caller uses a count of 0 to distinguish "first TUI-minted
@@ -1105,6 +1131,9 @@ class _ReaderState:
is later seen NO LONGER WAITING (answered in the agy TUI, or agy timed
out) to WITHDRAW the still-parked web card (#1200, direction 2). An entry
is removed once withdrawn so the withdraw posts at most once.
:param interaction_rescans: In-flight re-scan tasks scheduled by a bridge's
done-callback to surface a WAITING gate deferred while the bridge ran.
Held as strong refs so they are not GC'd mid-run; cancelled on teardown.
"""
allocator: _ToolCallIdAllocator
@@ -1121,6 +1150,7 @@ class _ReaderState:
cumulative_cache_read_input_tokens: int = 0
interaction_task: asyncio.Task[None] | None = None
surfaced_elicitations: dict[_StepKey, str] = field(default_factory=dict)
interaction_rescans: set[asyncio.Task[None]] = field(default_factory=set)
async def _poll_loop(
@@ -1719,8 +1749,11 @@ def _maybe_handle_interaction(
``bridge_interaction`` already owns those retries via its own freshest-WAITING
re-read, so spawning a second task for a retry step would surface a duplicate
elicitation and a competing delivery. Subsequent WAITING steps are skipped
while a task is active; its done-callback then clears the slot so a genuinely
new later interaction can fire.
while a task is active; its done-callback then clears the slot AND re-scans the
freshest steps (:func:`_resurface_pending_interaction`) so a genuinely-NEW gate
deferred during that window — e.g. the next segment of a chained ``a && b``
command, each gated separately — is surfaced even when no further stream frame
will carry it (agy stays parked on that gate, emitting none) (#1472).
The callback gets the SAME ``cascade_id`` + ``port`` (from ``state``) the
reader discovered, so the bridge targets agy's live conversation without
@@ -1760,23 +1793,107 @@ def _maybe_handle_interaction(
async def _run_bridge() -> None:
await on_pending_interaction(cascade_id, state.port, pending)
def _clear_slot(completed: asyncio.Task[None]) -> None:
if state.interaction_task is completed:
state.interaction_task = None
if not completed.cancelled():
exc = completed.exception()
def _clear_rescan(done: asyncio.Task[None]) -> None:
state.interaction_rescans.discard(done)
if not done.cancelled():
exc = done.exception()
if exc is not None:
_logger.warning(
"agy interaction bridge task failed (cascade=%s): %r",
"agy interaction re-scan task failed (cascade=%s): %r",
cascade_id,
exc,
)
def _clear_slot(completed: asyncio.Task[None]) -> None:
if state.interaction_task is completed:
state.interaction_task = None
if completed.cancelled():
# Reader teardown cancelled the bridge — the run is ending, so do NOT
# spawn a re-scan (teardown drains these tasks; a fresh one would race it).
return
exc = completed.exception()
if exc is not None:
_logger.warning(
"agy interaction bridge task failed (cascade=%s): %r",
cascade_id,
exc,
)
# Re-scan for a WAITING gate the single-in-flight guard deferred while this
# bridge ran (e.g. the next segment of a chained ``a && b`` command). agy
# emits no frame while parked on that gate, so without the re-scan it hangs.
# ``state.interacted`` makes already-surfaced steps no-ops.
rescan = asyncio.create_task(
_resurface_pending_interaction(
cascade_id=cascade_id,
state=state,
on_pending_interaction=on_pending_interaction,
),
name="antigravity-interaction-rescan",
)
state.interaction_rescans.add(rescan)
rescan.add_done_callback(_clear_rescan)
task = asyncio.create_task(_run_bridge(), name="antigravity-interaction-bridge")
state.interaction_task = task
task.add_done_callback(_clear_slot)
async def _resurface_pending_interaction(
*,
cascade_id: str,
state: _ReaderState,
on_pending_interaction: OnPendingInteraction,
) -> None:
"""
Re-surface a WAITING interaction the single-in-flight guard deferred.
Scheduled by ``_clear_slot`` after a bridge finishes. Re-reads the freshest
trajectory snapshot and re-dispatches every step through
:func:`_maybe_handle_interaction`; ``state.interacted`` makes already-surfaced
steps no-ops, so only the deferred gate fires. That gate spawns the next bridge,
whose clear re-scans again, draining a chain of sequential gates one at a time.
The snapshot read is retried up to :data:`_INTERACTION_RESCAN_POLL_ATTEMPTS` times
because this is the sole backstop on the healthy-stream path (agy emits no frame
while parked on the deferred gate; the poll loop is only the failure fallback).
:param cascade_id: agy cascade id (equal to the conversation id).
:param state: Per-run reader state.
:param on_pending_interaction: Async callback for a distinct interaction.
"""
steps: list[dict[str, object]] | None = None
for attempt in range(_INTERACTION_RESCAN_POLL_ATTEMPTS):
try:
steps = await asyncio.to_thread(get_trajectory_steps, state.port, cascade_id)
break
except (httpx.HTTPError, ValueError) as exc:
last = attempt == _INTERACTION_RESCAN_POLL_ATTEMPTS - 1
_logger.warning(
"agy interaction re-scan poll failed (cascade=%s, port=%s, attempt=%d/%d)%s: %r",
cascade_id,
state.port,
attempt + 1,
_INTERACTION_RESCAN_POLL_ATTEMPTS,
"; giving up — the poll fallback or a later frame must catch the deferred gate"
if last
else "; retrying",
exc,
)
if last:
return
await _sleep(_INTERACTION_RESCAN_POLL_BACKOFF_S)
if steps is None: # pragma: no cover - the loop returns on the last failure
return
for step in steps:
_maybe_handle_interaction(
step,
key=_step_key(step),
cascade_id=cascade_id,
state=state,
on_pending_interaction=on_pending_interaction,
)
async def _maybe_withdraw_interaction(
step: dict[str, object],
*,
+69 -15
View File
@@ -89,6 +89,7 @@ from omnigent.host.daemon_launch import (
wait_for_host_online,
wait_for_runner_online,
)
from omnigent.native_coding_agents import native_shell_terminal_spec
from omnigent.native_terminal import (
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
)
@@ -149,6 +150,17 @@ _UCODE_CLAUDE_TIER_TO_ENV: dict[str, str] = {
"sonnet": _ANTHROPIC_DEFAULT_SONNET_MODEL_ENV,
"haiku": _ANTHROPIC_DEFAULT_HAIKU_MODEL_ENV,
}
# The 4 family aliases above pin one model ID each. Claude Code has exactly
# one more independently-selectable /model picker slot beyond those
# families — ANTHROPIC_CUSTOM_MODEL_OPTION — used here to surface Sonnet 5
# as an opt-in *alongside* the "sonnet" alias, which stays pinned to the
# workspace's existing default Sonnet (4.6). This keeps the default Sonnet
# unchanged and adds the newer generation as a separate, explicit choice.
# See https://code.claude.com/docs/en/model-config#custom-model-options
_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV = "ANTHROPIC_CUSTOM_MODEL_OPTION"
_ANTHROPIC_CUSTOM_MODEL_OPTION_NAME_ENV = "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"
_UCODE_CLAUDE_CUSTOM_TIER = "sonnet_5"
_UCODE_CLAUDE_CUSTOM_TIER_LABEL = "Sonnet 5"
_DEFAULT_UCODE_AUTH_REFRESH_INTERVAL_MS = 900_000
_SESSION_LABELS = {
"omnigent.ui": "terminal",
@@ -310,6 +322,16 @@ def build_native_claude_terminal_env(
terminal_env.update(claude_config.env)
terminal_env[_CLAUDE_CODE_ENABLE_TOOL_SEARCH_ENV] = "true"
terminal_env[_CLAUDE_CODE_DISABLE_AGENT_VIEW_ENV] = "1"
# On the apiKeyHelper path the credential reaches Claude Code via the
# helper; a raw ANTHROPIC_API_KEY here re-triggers Claude Code's "Detected a
# custom API key" menu, which hangs tmux delivery. Fail loud if one leaks.
if claude_config is not None and claude_config.api_key_helper:
if _ANTHROPIC_API_KEY_ENV in terminal_env:
raise RuntimeError(
"native-claude: apiKeyHelper is configured but the terminal env "
f"carries a raw {_ANTHROPIC_API_KEY_ENV}; the credential must reach "
"Claude Code via the helper, not the environment."
)
return terminal_env
@@ -1449,6 +1471,10 @@ def _ucode_config_for_profile(profile: str | None) -> ClaudeNativeUcodeConfig |
model_id = workspace_state.claude_models.get(tier)
if model_id:
env[env_var] = model_id
custom_model_id = workspace_state.claude_models.get(_UCODE_CLAUDE_CUSTOM_TIER)
if custom_model_id:
env[_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV] = custom_model_id
env[_ANTHROPIC_CUSTOM_MODEL_OPTION_NAME_ENV] = _UCODE_CLAUDE_CUSTOM_TIER_LABEL
# When ucode caches no model, default it so Claude Code doesn't fall back
# to its host-config model (an Anthropic-direct id the gateway rejects).
return ClaudeNativeUcodeConfig(
@@ -1770,20 +1796,10 @@ def _materialize_claude_agent_spec(tmpdir: Path) -> Path:
# Declare a default shell terminal so the relay advertises the
# ``sys_terminal_*`` family to the wrapped Claude Code (the
# relay's gate is a non-empty ``terminals:`` block on this
# spec). Caller process / no sandbox matches the ``os_env``
# stance above — the native CLI already runs unsandboxed on
# the user's workspace.
"terminals": {
"shell": {
"command": "bash",
"allow_cwd_override": True,
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {"type": "none"},
},
},
},
# spec). Its command follows the user's ``$SHELL`` (zsh/fish/bash);
# caller process / no sandbox matches the ``os_env`` stance above —
# the native CLI already runs unsandboxed on the user's workspace.
"terminals": native_shell_terminal_spec(),
}
yaml_path.write_text(yaml.safe_dump(raw, sort_keys=False))
return yaml_path
@@ -3525,6 +3541,14 @@ def _claude_transcript_records_from_session_items(
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
"uuid": boundary_uuid,
"level": "info",
# Claude scans every compact_boundary and destructures
# compactMetadata; a missing object crashes /compact
# (and auto-compact) on resume. token_count is the
# post-compaction summary size.
"compactMetadata": {
"trigger": "auto",
"postTokens": item.get("token_count"),
},
}
)
parent_uuid = boundary_uuid
@@ -3657,7 +3681,7 @@ def _claude_transcript_record_from_session_item(
}
],
}
extra["toolUseResult"] = output
extra["toolUseResult"] = _json_safe_tool_use_result(output)
else:
return None
return {
@@ -3777,6 +3801,36 @@ def _json_object_from_string(value: object) -> dict[str, Any]:
return parsed if isinstance(parsed, dict) else {}
def _json_safe_tool_use_result(output: str) -> str:
"""
Return a ``toolUseResult`` value Claude Code can ``JSON.parse``.
Some built-in result renderers (notably ``TaskOutput``) call
``JSON.parse`` on ``toolUseResult`` when the transcript is resumed.
A raw display string such as ``"<retrieval_status>timeout</...>"``
throws ``JSON Parse error: Unrecognized token '<'`` at TUI boot,
before the input prompt renders — so the whole resume fails and the
first web-UI message is never delivered.
Outputs that are already JSON (e.g. an image content-block array)
pass through verbatim; anything else is wrapped as a JSON string
literal so the parse always succeeds. The plain-text output still
lives verbatim in the ``tool_result`` content block, so this does
not change what the model or the web UI sees.
:param output: The tool result string synthesized for the
transcript, e.g. ``"<retrieval_status>timeout</...>"`` or
``'[{"type":"image",...}]'``.
:returns: A JSON-parseable string for the record's
``toolUseResult`` field.
"""
try:
json.loads(output)
except (json.JSONDecodeError, ValueError):
return json.dumps(output)
return output
def _preflight_local_tools(command: str) -> None:
"""
Verify local executables required by the native Claude wrapper.
+197 -15
View File
@@ -124,6 +124,14 @@ _TMUX_SEND_TIMEOUT_S = 5.0
# The glyph persists while Claude is busy responding, so its presence
# means "input box mounted" (not "idle"), which is what injection needs.
_CLAUDE_PROMPT_GLYPH = ""
# Matches a selected numbered menu row (`` 2. No (recommended)``): the glyph
# followed by a numbered choice, which the chat input never renders. Used to
# exclude startup menus from the readiness scan (see ``_is_selected_menu_row``).
_SELECTED_MENU_ROW_RE = re.compile(rf"{_CLAUDE_PROMPT_GLYPH}\s*\d+\.\s")
# Box-drawing glyphs Claude Code's input-box frame is made of. A line of
# these below ```` marks the live input box (see ``_is_box_rule``),
# distinguishing it from a bare prompt echoed into scrollback.
_BOX_RULE_CHARS = frozenset("─━╭╮╰╯│┃╌╍")
# How many trailing non-empty lines to scan for the prompt glyph. The
# input box sits near the bottom of the pane; scanning only the tail
# avoids false positives from the glyph appearing in scrollback output.
@@ -277,11 +285,18 @@ def _trusted_parent_for_bridge_dir(target: Path) -> Path:
# bridge-owned directories below it.
return _absolute_syntactic_path(kiro_root.parent.parent)
# Headless ACP harnesses (acp / goose / qwen) put their Omnigent-MCP relay
# bridge below ``$TMPDIR/omnigent-<uid>/acp-mcp`` (same uid-scoped shape as
# cursor/qwen/hermes-native), so trust the uid-scoped temp dir's parent.
acp_root = _absolute_syntactic_path(acp_mcp_bridge_root())
if target.is_relative_to(acp_root):
return _absolute_syntactic_path(acp_root.parent.parent)
raise RuntimeError(
f"bridge dir {target!s} is not under an allowed bridge root "
f"({claude_root!s}, {codex_root!s}, {cursor_root!s}, "
f"{antigravity_root!s}, {qwen_root!s}, {hermes_root!s}, {opencode_root!s}, "
f"{kiro_root!s})"
f"{kiro_root!s}, {acp_root!s})"
)
@@ -700,6 +715,38 @@ def _ensure_secure_dir(target: Path) -> None:
os.chmod(ancestor, 0o700)
def acp_mcp_bridge_root() -> Path:
"""Bridge root for the headless ACP harnesses' Omnigent-MCP relay.
Shares the uid-scoped temp parent with claude-native
(``$TMPDIR/omnigent-<uid>/acp-mcp``). Used by the acp / goose / qwen
executors' ``OmnigentAcpMcp`` relay so ``serve-mcp``'s bridge dir passes the
:func:`_trusted_parent_for_bridge_dir` secure-root check.
:returns: The ACP-MCP bridge root directory (not created here).
"""
return _BRIDGE_ROOT_PARENT / "acp-mcp"
def prepare_acp_mcp_bridge_dir() -> Path:
"""Create a fresh, secure per-relay bridge dir for an ACP harness.
Returns a unique owner-only directory under :func:`acp_mcp_bridge_root` with
a minimal token-only ``bridge.json`` — so the shared ``serve-mcp`` serves
ONLY the relay tools (no raw ``sys_os_*`` filesystem tools; the ACP agent
owns those). The caller's relay writes ``tool_relay.json`` here and points
``serve-mcp`` at the directory.
:returns: The prepared bridge directory path.
"""
bridge_dir = acp_mcp_bridge_root() / secrets.token_hex(8)
_ensure_secure_dir(bridge_dir)
config_path = bridge_dir / _CONFIG_FILE
if not config_path.exists():
_write_json_file(config_path, {"token": secrets.token_urlsafe(32)})
return bridge_dir
def bridge_dir_for_bridge_id(bridge_id: str) -> Path:
"""
Return the deterministic bridge directory for a Claude-native bridge.
@@ -1043,6 +1090,9 @@ def build_hook_settings(
ap_server_url: str | None = None,
ap_auth_headers: dict[str, str] | None = None,
api_key_helper: str | None = None,
launch_model: str | None = None,
launch_permission_mode: str | None = None,
launch_effort: str | None = None,
) -> dict[str, Any]:
"""
Build invocation-local Claude Code hook settings.
@@ -1062,6 +1112,15 @@ def build_hook_settings(
:param api_key_helper: Optional Claude Code ``apiKeyHelper``
command from ucode state, e.g. ``"databricks auth token
--host https://example.databricks.com ..."``.
:param launch_model: Effective launch model from ``--model``. Mirrored
into the invocation-local settings sidecar so a wrapped Claude Code
re-exec that preserves ``--settings`` but rebuilds argv cannot fall
back to the user's global default model.
:param launch_permission_mode: Effective launch permission mode from
``--permission-mode``. Mirrored into ``permissions.defaultMode``
for the same re-exec hardening.
:param launch_effort: Effective launch effort from ``--effort``.
Mirrored into ``effortLevel`` for restart/re-exec parity.
:returns: JSON-serializable Claude settings fragment.
"""
python = python_executable or sys.executable
@@ -1247,6 +1306,12 @@ def build_hook_settings(
# prompts, since both fire UserPromptSubmit.
hooks["UserPromptSubmit"].append({"hooks": [evaluate_policy_hook]})
settings: dict[str, Any] = {"hooks": hooks}
if launch_model:
settings["model"] = launch_model
if launch_permission_mode:
settings["permissions"] = {"defaultMode": launch_permission_mode}
if launch_effort and launch_effort in CLAUDE_EFFORTS:
settings["effortLevel"] = launch_effort
if api_key_helper:
settings["apiKeyHelper"] = api_key_helper
# Override Claude Code's statusLine so we receive its stdin (the
@@ -1343,6 +1408,9 @@ def augment_claude_args(
ap_server_url=ap_server_url,
ap_auth_headers=ap_auth_headers,
api_key_helper=api_key_helper,
launch_model=_arg_value(claude_args, "--model"),
launch_permission_mode=_arg_value(claude_args, "--permission-mode"),
launch_effort=_arg_value(claude_args, "--effort"),
)
args = _merge_disallowed_tools(list(claude_args), _OMNIGENT_DISALLOWED_TOOLS)
args.extend(
@@ -1363,6 +1431,32 @@ def augment_claude_args(
return args
def _arg_value(args: tuple[str, ...], flag: str) -> str | None:
"""Return the effective CLI flag value from ``args``.
Supports both ``--flag value`` and ``--flag=value`` spellings. When a
flag appears more than once, the last valid occurrence wins, matching the
usual CLI precedence for repeated long options.
:param args: Claude CLI args, e.g. ``("--model", "sonnet")``.
:param flag: Long flag to read, e.g. ``"--model"``.
:returns: The flag value, or ``None`` when absent/empty.
"""
joined_prefix = f"{flag}="
value: str | None = None
for idx, arg in enumerate(args):
if arg.startswith(joined_prefix):
candidate = arg[len(joined_prefix) :]
if candidate:
value = candidate
continue
if arg == flag and idx + 1 < len(args):
candidate = args[idx + 1]
if candidate and not candidate.startswith("--"):
value = candidate
return value
def _merge_disallowed_tools(args: list[str], extra: tuple[str, ...]) -> list[str]:
"""
Add ``extra`` tool names to a ``--disallowedTools`` flag in ``args``.
@@ -2831,11 +2925,77 @@ def _claude_prompt_rendered(pane: str) -> bool:
positives from the glyph appearing in scrollback (e.g. echoed in a
prior response), since the live input box always sits at the bottom.
A mid-turn injection grows the footer with running-state rows (a
``○ Explore …`` subagent line, extra spinners) that can push ````
past that window — arbitrarily far, since a subagent fan-out adds one
row per concurrent subagent. To reach it at any depth without also
matching a scrollback echo, a glyph above the window counts only when
it's framed by a box rule — the ``────`` closing line the live input
box always renders below ```` but a bare echoed prompt never has.
A bare ```` on a selected numbered menu row is not the chat input. A
numbered line with an input-box rule below it still counts, however: the
readiness gate runs before every injection, so a restored composer draft
may legitimately begin with text such as ``2. buy milk``.
:param pane: Captured pane text from :func:`_capture_pane`.
:returns: ``True`` when the input box appears mounted.
"""
non_empty = [line for line in pane.splitlines() if line.strip()]
return any(_CLAUDE_PROMPT_GLYPH in line for line in non_empty[-_PROMPT_SCAN_TAIL_LINES:])
tail_start = max(0, len(non_empty) - _PROMPT_SCAN_TAIL_LINES)
for idx in range(tail_start, len(non_empty)):
line = non_empty[idx]
if _CLAUDE_PROMPT_GLYPH not in line:
continue
if not _is_selected_menu_row(line) or any(
_is_box_rule(rule) for rule in non_empty[idx + 1 :]
):
return True
# Above that window, trust the glyph only when a box rule sits below
# it — the live input box's closing frame, absent from scrollback.
# The footer height scales with concurrent subagents (a fan-out of
# ``○ Explore …`` rows), so no fixed window can bound it; the box rule
# is a reliable structural signal at any depth, and `capture-pane -p`
# returns only the visible pane, so this stays within one screen.
for idx, line in enumerate(non_empty):
if _CLAUDE_PROMPT_GLYPH not in line:
continue
if any(_is_box_rule(rule) for rule in non_empty[idx + 1 :]):
return True
return False
def _is_selected_menu_row(line: str) -> bool:
"""
Return whether a ```` line is a selected numbered menu row.
Claude Code's startup menus (e.g. the "Detected a custom API key"
confirmation) mark the highlighted choice with the same ```` glyph the
chat input uses (`` 2. No (recommended)``). The readiness scan must not
treat such a row as the chat composer, or the first message gets typed
into the menu. A chat prompt never renders a numbered choice after the
glyph, so the ``<glyph> <digit>.`` shape distinguishes them.
:param line: A single pane line, e.g. ``" 2. No (recommended)"``.
:returns: ``True`` when the line is a selected numbered menu choice.
"""
return bool(_SELECTED_MENU_ROW_RE.match(line.strip()))
def _is_box_rule(line: str) -> bool:
"""
Return whether a line is a TUI box-drawing horizontal rule.
Claude Code frames its input box with rows of ``─`` (plus corner
glyphs). Such a rule below ```` marks the live input box, letting
the readiness scan reach a prompt buried under a tall running-turn
footer without matching a bare ```` echoed into scrollback.
:param line: A single pane line, e.g. ``"──────────"``.
:returns: ``True`` when the line is predominantly box-rule glyphs.
"""
stripped = line.strip()
return len(stripped) >= 3 and all(ch in _BOX_RULE_CHARS for ch in stripped)
def _submit_needle(content: str) -> str:
@@ -2950,25 +3110,47 @@ def _wait_for_claude_prompt_ready(
:param timeout_s: Seconds to wait for the prompt, e.g. ``30.0``.
:returns: None.
:raises RuntimeError: If the prompt never renders within
*timeout_s* (Claude failed to boot). The message carries the
tail of the captured pane (see :func:`_format_terminal_failure_tail`)
so Claude Code's own startup output surfaces in the caller's error.
*timeout_s* (Claude failed to boot). The message carries a poll
count, how many of those polls saw an empty capture, and the tail
of the last non-empty capture the loop actually observed (see
:func:`_format_terminal_failure_tail`) so the true failure mode —
a startup crash, a torn/empty capture under a mid-turn repaint, or
a box that never appeared — is diagnosable from the error alone.
"""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if _claude_prompt_rendered(_capture_pane(socket_path, tmux_target)):
polls = 0
empty_polls = 0
# Keep the last non-empty capture the loop actually saw, not a fresh
# capture taken after the deadline. A post-timeout re-capture can show
# a different (often healthier-looking) frame than any decision the
# loop made — e.g. the input box repainting just as the turn settles —
# which misrepresents why the gate failed. Attaching what was observed
# while it mattered keeps the error honest.
last_nonempty = ""
# Poll at least once even at timeout_s=0: a single readiness check is
# still meaningful, and it guarantees a capture to attach on failure.
while True:
pane = _capture_pane(socket_path, tmux_target)
polls += 1
if pane.strip():
last_nonempty = pane
else:
empty_polls += 1
if _claude_prompt_rendered(pane):
return
if time.monotonic() >= deadline:
break
time.sleep(_CLAUDE_READY_POLL_INTERVAL_S)
# Timed out: Claude Code never rendered its input prompt. Capture the
# pane one last time and attach its tail so the real cause — often a
# startup crash like a ``JSON Parse error`` — surfaces in the web UI
# error banner this raises into, instead of only a generic timeout
# the user has to open the terminal to diagnose.
pane = _capture_pane(socket_path, tmux_target)
# Timed out. The poll/empty-capture counts separate the failure modes:
# mostly-empty captures point at a torn read under a busy repaint (the
# session is alive but capture-pane came back blank); non-empty captures
# with no box point at Claude never rendering the prompt (a boot crash,
# e.g. a ``JSON Parse error``, whose text the tail then surfaces).
raise RuntimeError(
f"Claude Code terminal did not become ready within {timeout_s}s "
"(input prompt never rendered). The message was not delivered."
+ _format_terminal_failure_tail(pane)
f"(input prompt never rendered in {polls} polls, "
f"{empty_polls} empty captures). The message was not delivered."
+ _format_terminal_failure_tail(last_nonempty)
)
+48 -6
View File
@@ -2839,6 +2839,31 @@ async def _ensure_state_for_transcript(
return state
def _turn_has_assistant_output(items: list[ClaudeTranscriptItem], response_id: str) -> bool:
"""
Whether ``response_id`` has assistant-generated output among ``items``.
The turn-start ``running`` edge should open a streaming turn only for an id
that a later ``Stop``/``StopFailure`` hook will close i.e. one produced by
an actual LLM turn. Assistant text (``message`` with ``role=assistant``) and
tool calls (``function_call``) qualify; a ``slash_command`` (``/model``,
``/effort``) or ``terminal_command`` (``!cmd``) item opens an id with no LLM
turn behind it, so it must not.
:param items: Transcript items read this poll.
:param response_id: The current turn's response id.
:returns: ``True`` when an assistant-output item carries ``response_id``.
"""
for item in items:
if item.response_id != response_id:
continue
if item.item_type == "function_call":
return True
if item.item_type == "message" and item.data.get("role") == "assistant":
return True
return False
async def _forward_available_items(
*,
client: httpx.AsyncClient,
@@ -2902,9 +2927,19 @@ async def _forward_available_items(
# status post must not abort item forwarding (the items below are the
# primary payload); the turn-end idle/failed edge still carries the id to
# close the lifecycle, and the badge is unaffected either way.
#
# Only open the streaming turn for an id that has ASSISTANT output in this
# poll's items. A surfaced CLI built-in (``/model``, ``/effort``) or a
# ``!cmd`` becomes a slash_command / terminal_command item that opens its
# own response id but runs no LLM turn, so no ``Stop`` hook ever fires to
# close it — a ``running`` opened for it would strand the web composer in
# its "Stop"/busy state until the next real message. A skill that DOES
# trigger an LLM turn shares its id with the assistant text it produces, so
# ``running`` still fires — one poll later, when that output appears.
if (
current_response_id is not None
and dedupe.posted_running_response_id != current_response_id
and _turn_has_assistant_output(items, current_response_id)
):
try:
await post_external_session_status(
@@ -3611,22 +3646,29 @@ def _model_alias_for(model: str | None) -> str | None:
Collapse a concrete Claude model id to the picker's tier alias.
The web model picker speaks Claude Code's version-agnostic aliases
(``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"haiku"``); the
(``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"haiku"``), plus the one
extra concrete-id slot ``"sonnet_5"`` (see
:data:`omnigent.claude_native._UCODE_CLAUDE_CUSTOM_TIER`) for the newer
Sonnet generation offered alongside the default ``"sonnet"`` tier; the
transcript records the resolved concrete id (e.g.
``"claude-opus-4-8"`` or ``"databricks-claude-sonnet-4-6"``).
``"claude-opus-4-8"`` or ``"databricks-claude-sonnet-5"``).
Mapping to the tier keeps the mirrored value in the picker's
vocabulary and makes a webTUI round-trip a no-op.
vocabulary and makes a webTUI round-trip a no-op. The older Sonnet
(``sonnet-4-6``) collapses to the generic ``"sonnet"`` alias it is the
default that row is bound to.
:param model: Concrete model id from the transcript, e.g.
``"claude-opus-4-8"``; ``None`` when none observed yet.
:returns: ``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"haiku"``
when the id carries a known tier token, else ``None`` (the
caller skips the post rather than surface an id the picker
:returns: ``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"sonnet_5"`` /
``"haiku"`` when the id carries a known tier token, else ``None``
(the caller skips the post rather than surface an id the picker
can't render).
"""
if not model:
return None
lowered = model.lower()
if "sonnet-5" in lowered or "sonnet_5" in lowered:
return "sonnet_5"
for tier in ("fable", "opus", "sonnet", "haiku"):
if tier in lowered:
return tier
+7 -5
View File
@@ -1039,23 +1039,25 @@ def _main_evaluate_policy(argv: list[str]) -> int:
# The session is governed (active id + ap_server_url) and we have a
# policy-relevant event: from here a failure to obtain a usable verdict
# fails CLOSED for the tool-call gate (see ``fail_closed_hook_output``).
def _fail_closed() -> int:
out = fail_closed_hook_output(hook_event)
reauth = policy_hook_reauth(ap_server_url, headers)
def _fail_closed(detail: str | None = None) -> int:
out = fail_closed_hook_output(hook_event, detail)
if out is not None:
sys.stdout.write(json.dumps(out))
return 0
url = f"{ap_server_url.rstrip('/')}/v1/sessions/{url_component(session_id)}/policies/evaluate"
resp = post_evaluate_with_retry(
resp, api_error = post_evaluate_with_retry(
url,
headers,
eval_request,
_EVALUATE_POLICY_TIMEOUT_S,
"evaluate-policy hook",
reauth=policy_hook_reauth(ap_server_url, headers),
reauth=reauth,
)
if resp is None:
return _fail_closed()
return _fail_closed(api_error or reauth.failure_reason)
if not resp.content:
print("omnigent evaluate-policy hook: empty Omnigent response", file=sys.stderr)
return _fail_closed()
+496 -56
View File
@@ -33,6 +33,11 @@ from omnigent._platform import IS_WINDOWS, resolve_repo_symlink
from omnigent._startup_profile import StartupProfiler
from omnigent.cli_sandbox import lakebox as _lakebox_alias_group
from omnigent.cli_sandbox import sandbox as _sandbox_group
from omnigent.config import (
global_config_path,
load_global_config,
load_local_config,
)
from omnigent.harness_aliases import canonicalize_harness
from omnigent.host.local_server import (
_DEFAULT_LOCAL_PORT,
@@ -233,11 +238,14 @@ _DAEMON_RECONNECT_GRACE_S = 5.0
_DAEMON_REUSE_MIN_AGE_S = 6.0
# How long uvicorn waits for active connections (WebSocket, SSE) after
# SIGTERM before force-closing them. 30 s gives in-flight responses time
# to drain while still guaranteeing the port is released promptly.
# SIGTERM before force-closing them. SSE streams signal themselves via
# session_stream.shutdown_all() in _ShutdownSignalingServer.shutdown(),
# so the main remaining consumers of this window are WebSocket tunnels
# that need a moment to drain. 5 s is enough for a clean tunnel teardown
# while keeping Ctrl-C feeling instant.
# Overridable via OMNIGENT_SERVER_SHUTDOWN_TIMEOUT_S for deployments that
# need a longer drain window (e.g. large file uploads).
_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S_DEFAULT = 30
_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S_DEFAULT = 5
_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S = int(
os.environ.get(
"OMNIGENT_SERVER_SHUTDOWN_TIMEOUT_S",
@@ -296,9 +304,7 @@ def _effective_global_config_path() -> Path:
:returns: ``$OMNIGENT_CONFIG_HOME/config.yaml`` when the env
override is set, otherwise :data:`_GLOBAL_CONFIG_PATH`.
"""
if config_home := os.environ.get(_CONFIG_HOME_ENV_VAR):
return Path(config_home) / "config.yaml"
return _GLOBAL_CONFIG_PATH
return global_config_path(_GLOBAL_CONFIG_PATH)
def _display_path(path: Path) -> str:
@@ -357,12 +363,7 @@ def _load_global_config() -> dict[str, Any]: # type: ignore[explicit-any]
``{"default_agent": "examples/hello_world.yaml",
"auth": {"type": "databricks", "profile": "oss"}}``.
"""
path = _effective_global_config_path()
if not path.exists():
return {}
with open(path) as f:
raw: dict[str, Any] = yaml.safe_load(f) or {} # type: ignore[explicit-any]
return raw
return load_global_config(_effective_global_config_path())
def _load_local_config() -> dict[str, Any]: # type: ignore[explicit-any]
@@ -373,12 +374,7 @@ def _load_local_config() -> dict[str, Any]: # type: ignore[explicit-any]
:returns: Parsed YAML as a dict.
"""
path = Path.cwd() / _LOCAL_CONFIG_RELPATH
if not path.exists():
return {}
with open(path) as f:
raw: dict[str, Any] = yaml.safe_load(f) or {} # type: ignore[explicit-any]
return raw
return load_local_config(Path.cwd() / _LOCAL_CONFIG_RELPATH)
def _load_effective_config() -> dict[str, Any]: # type: ignore[explicit-any]
@@ -1196,6 +1192,7 @@ _CLICK_SUBCOMMANDS: frozenset[str] = frozenset(
"qwen",
"resume",
"run",
"session",
"sandbox",
"server",
"setup",
@@ -2597,8 +2594,8 @@ def _start_cli_runner_process(
``~/.omnigent/logs`` location; tests should pass a
temporary directory to avoid writing to the developer's
real home.
:param prewarm_spec_path: Optional YAML path; the runner spawns
its MCPs during the upload window. See designs/RUNNER_MCP.md.
:param prewarm_spec_path: Optional YAML path; the runner registers
its MCP routing metadata during startup without opening transports.
:param isolate_session: ``True`` for shared-host runners;
enables per-session workspace isolation so each
session gets its own subdirectory. ``False`` (default)
@@ -2972,6 +2969,7 @@ def server(
port = _picked
import uvicorn
import uvicorn.server
from omnigent.runner.transports.ws_tunnel.limits import (
RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
@@ -3220,34 +3218,71 @@ def server(
# this foreground server instead of tearing it down on a spurious
# sig mismatch.
register_local_server(port)
class _ShutdownSignalingServer(uvicorn.server.Server):
"""uvicorn.Server that signals active SSE subscribers before the
graceful-shutdown wait starts.
uvicorn calls ``Server.shutdown()`` in this order:
1. close listening sockets / call connection.shutdown()
2. ``asyncio.wait_for(_wait_tasks_to_complete(), timeout=)``
3. force-cancel remaining tasks on timeout
4. run the ASGI lifespan shutdown handler
The ASGI lifespan ``finally`` block runs at step 4 too late. SSE
generators waiting on a heartbeat tick are already force-cancelled by
step 3, which produces spurious ``CancelledError`` tracebacks.
Overriding here lets us drain SSE streams before step 2 so they exit
cleanly within the graceful window.
"""
async def shutdown(self, sockets=None) -> None: # type: ignore[override]
import asyncio as _asyncio
from omnigent.runtime import session_stream as _session_stream
_session_stream.shutdown_all()
# Yield to the event loop so generators can consume _DONE,
# flush their final "data: [DONE]\n\n" chunk, and exit before
# super().shutdown() calls connection.shutdown() / transport.close().
# Without this pause the generators write to an already-closing
# transport, leaving connections open past the graceful window.
await _asyncio.sleep(0)
await super().shutdown(sockets)
_config = uvicorn.Config(
app,
host=host,
port=port,
log_config=_server_uvicorn_log_config(),
ws_max_size=RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
# Server side of the runner/host tunnels' protocol keepalive, aligned
# to the 90 s app-level budget instead of uvicorn's 20 s default that
# drops a busy-but-healthy tunnel with 1011 — issue #1116.
#
# uvicorn's ws_ping_* is server-global (no per-route override), so this
# 30 s/90 s budget also applies to the app's other WebSocket routes —
# /v1/sessions/updates (browser stream) and .../terminals/{id}/attach.
# Deliberate and acceptable: for an IDLE such socket the protocol
# PING/PONG is the only half-open detector (the sessions-updates
# heartbeat is a server->client send, and an idle terminal has no
# traffic), so widening it means a dead idle browser/terminal socket is
# reaped at worst ~120 s (30 s interval + 90 s timeout) instead of
# ~40 s — a slightly later half-open cleanup (e.g. the out-of-process
# terminal-attach proxy holds its runner socket + tmux child ~80 s
# longer), bounded and eventually reaped, not a leak or correctness
# change. The tunnels are the sockets that actually need the looser
# budget (issue #1116).
ws_ping_interval=TUNNEL_KEEPALIVE_PING_INTERVAL_S,
ws_ping_timeout=TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
timeout_graceful_shutdown=_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S,
)
try:
uvicorn.run(
app,
host=host,
port=port,
log_config=_server_uvicorn_log_config(),
ws_max_size=RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
# Server side of the runner/host tunnels' protocol keepalive, aligned
# to the 90 s app-level budget instead of uvicorn's 20 s default that
# drops a busy-but-healthy tunnel with 1011 — issue #1116.
#
# uvicorn's ws_ping_* is server-global (no per-route override), so this
# 30 s/90 s budget also applies to the app's other WebSocket routes —
# /v1/sessions/updates (browser stream) and .../terminals/{id}/attach.
# Deliberate and acceptable: for an IDLE such socket the protocol
# PING/PONG is the only half-open detector (the sessions-updates
# heartbeat is a server->client send, and an idle terminal has no
# traffic), so widening it means a dead idle browser/terminal socket is
# reaped at worst ~120 s (30 s interval + 90 s timeout) instead of
# ~40 s — a slightly later half-open cleanup (e.g. the out-of-process
# terminal-attach proxy holds its runner socket + tmux child ~80 s
# longer), bounded and eventually reaped, not a leak or correctness
# change. The tunnels are the sockets that actually need the looser
# budget (issue #1116).
ws_ping_interval=TUNNEL_KEEPALIVE_PING_INTERVAL_S,
ws_ping_timeout=TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
timeout_graceful_shutdown=_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S,
)
_ShutdownSignalingServer(_config).run()
except KeyboardInterrupt:
# uvicorn.run() swallows KeyboardInterrupt; match that behaviour so
# a Ctrl-C exit doesn't print Click's "Aborted!" or exit non-zero.
pass
finally:
if _is_canonical_local_server:
clear_local_server_record()
@@ -5501,6 +5536,112 @@ def resume(
)
@cli.group("session", invoke_without_command=True)
@click.pass_context
def session(ctx: click.Context) -> None:
"""Manage Omnigent sessions.
\b
Examples:
omnigent session export --id conv_abc123
omnigent session export --id conv_abc123 --output transcript.jsonl
omnigent session export --id conv_abc123 --server https://myserver.com
"""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@session.command("export")
@click.option(
"--id",
"session_id",
required=True,
metavar="SESSION_ID",
help="Session ID to export, e.g. conv_abc123.",
)
@click.option(
"--output",
"-o",
"output",
default=None,
metavar="FILE",
help="Output file path. Defaults to <SESSION_ID>.jsonl in the current directory.",
)
@click.option(
"--server",
default=None,
help=(
"Omnigent server URL. "
"Defaults to the configured server, or a local server already running."
),
)
def session_export(session_id: str, output: str | None, server: str | None) -> None:
"""Export a session transcript to a portable JSONL file.
Each line of the output is a JSON object. The first line carries
the session metadata (``"record_type": "session_meta"``); every
subsequent line is one conversation item
(``"record_type": "item"``). The file preserves full turn order
and can be re-imported with a future ``omnigent session import``.
\b
Examples:
omnigent session export --id conv_abc123
omnigent session export --id conv_abc123 --output my_session.jsonl
omnigent session export --id conv_abc123 --server https://myserver.com
"""
import httpx
from omnigent.chat import _remote_headers
cfg = _load_effective_config()
base_url = _resolve_attach_server(server, cfg.get("server"))
if base_url is None:
startup = ensure_local_omnigent_server()
base_url = startup.url
base_url = base_url.rstrip("/")
out_path = Path(output) if output else Path(f"{session_id}.jsonl")
with httpx.Client(
base_url=base_url, headers=_remote_headers(server_url=base_url), timeout=30.0
) as client:
# Fetch session metadata (items fetched separately via pagination).
resp = client.get(
f"/v1/sessions/{session_id}",
params={"include_items": "false", "include_liveness": "false"},
)
if resp.status_code == 404:
raise click.ClickException(f"Session {session_id!r} not found.")
resp.raise_for_status()
session_data = resp.json()
n_items = 0
with out_path.open("w", encoding="utf-8") as fh:
# First line: session metadata.
meta_record = {"record_type": "session_meta", **session_data}
fh.write(json.dumps(meta_record) + "\n")
# Remaining lines: items in ascending order, paginated.
after: str | None = None
while True:
params: dict[str, str | int] = {"limit": 500, "order": "asc"}
if after:
params["after"] = after
items_resp = client.get(f"/v1/sessions/{session_id}/items", params=params)
items_resp.raise_for_status()
page = items_resp.json()
for item in page["data"]:
item_record = {"record_type": "item", **item}
fh.write(json.dumps(item_record) + "\n")
n_items += 1
if not page.get("has_more"):
break
after = page.get("last_id")
click.echo(f"Exported {n_items} item(s) from {session_id} to {out_path}")
# Shared option help for ``run`` and the harness commands. These are the same
# flags the legacy argparse CLI exposed — keeping them on the unified
# click CLI so users don't regress when a YAML declares no executor
@@ -5622,22 +5763,34 @@ def _materialize_harness_launcher_file(
:raises click.ClickException: If *harness* is unsupported.
"""
_validate_harness(harness)
display_name = harness
harness = canonicalize_harness(harness) or harness
canonical = canonicalize_harness(harness) or harness
# An acp:<slug> harness id carries a colon: it canonicalizes to the base
# `acp` harness, but the slug selects a user-configured ACP agent resolved
# at spawn and must be preserved. So the effective harness id written to
# executor.harness is the FULL acp:<slug> (keep the slug), or the canonical
# id for every other harness (so aliases still resolve, e.g. kimi ->
# kimi-code). The agent NAME and temp filename must be path-safe /
# [a-zA-Z0-9_-]+, so the colon is sanitized there only.
effective_harness = harness if canonical == "acp" and ":" in harness else canonical
# Name preserves the user's input (matching the pre-acp behavior, e.g.
# --harness claude -> name "claude"), sanitized for the colon so acp:<slug>
# yields a valid [a-zA-Z0-9_-]+ name. Filename uses the canonical/effective
# id (also colon-sanitized) as before.
display_name = harness.replace(":", "-")
tmpdir = Path(tempfile.mkdtemp(prefix="omnigent-harness-launcher-"))
yaml_path = tmpdir / f"{harness}.yaml"
yaml_path = tmpdir / f"{effective_harness.replace(':', '-')}.yaml"
executor: dict[str, str] = {"harness": harness}
executor: dict[str, str] = {"harness": effective_harness}
if model is not None:
executor["model"] = model
raw = {
"name": display_name,
"prompt": system_prompt or _default_harness_prompt(harness),
"prompt": system_prompt or _default_harness_prompt(canonical),
"executor": executor,
}
if harness in _OS_ENV_HARNESSES:
if canonical in _OS_ENV_HARNESSES:
raw["os_env"] = {"type": "caller_process", "sandbox": {"type": "none"}}
yaml_path.write_text(yaml.safe_dump(raw, default_flow_style=False))
return yaml_path
@@ -9986,6 +10139,83 @@ def _manage_goose_harness() -> None:
status = None
def _print_acp_examples() -> None:
"""Print example ACP-agent commands (Omnigent stores no credential)."""
from omnigent.onboarding.interactive import console
console.print(
"\n [bold]Custom ACP agents[/bold] — connect any agent that speaks the "
"Agent Client Protocol ([underline]agentclientprotocol.com[/underline]).\n"
" Omnigent stores no credential — log into each agent via its own CLI first.\n\n"
" Example commands to paste:\n"
" • Gemini CLI [bold]gemini --experimental-acp[/bold]\n"
" • Qwen Code [bold]qwen --acp[/bold]\n"
" • Goose [bold]goose acp[/bold]\n"
" • Claude Code [bold]npx -y @zed-industries/claude-code-acp[/bold]\n"
)
def _add_acp_agent() -> None:
"""Prompt for a new ACP agent and append it to the ``acp:`` config block.
Reached straight from the "Add custom ACP agent" overview row (no
intermediate menu). Prints the paste-ready examples first, then prompts for
name / command / optional model.
"""
from omnigent.onboarding.acp_auth import (
AcpAgentEntry,
acp_agents,
acp_agents_settings,
slugify,
)
from omnigent.onboarding.interactive import console, prompt_text
_print_acp_examples()
name = prompt_text("Agent name (e.g. Gemini CLI)").strip()
if not name:
console.print(" [yellow]No name entered — nothing added.[/yellow]")
return
command = prompt_text("Command to launch (e.g. gemini --experimental-acp)").strip()
if not command:
console.print(" [yellow]No command entered — nothing added.[/yellow]")
return
model = (prompt_text("Model (optional — Enter to skip)", default="") or "").strip() or None
entries = list(acp_agents())
entries.append(AcpAgentEntry(slug=slugify(name), name=name, command=command, model=model))
_save_global_config(acp_agents_settings(entries))
console.print(f" ✓ Added {name}")
def _manage_acp_agent(slug: str) -> None:
"""Per-agent drill-in for one configured ACP agent: remove it.
Reached by selecting the agent's own row in the configure-harnesses overview.
A single-shot menu (Remove / Back) Omnigent stores no credential, so there
is nothing else to manage per agent yet.
:param slug: The agent's slug (see :func:`omnigent.onboarding.acp_auth.slugify`).
"""
from omnigent.onboarding.acp_auth import acp_agents, acp_agents_settings
from omnigent.onboarding.interactive import console, select
agents = list(acp_agents())
agent = next((a for a in agents if a.slug == slug), None)
if agent is None:
return
suffix = f" · {agent.model}" if agent.model else ""
header = f"{agent.name}{agent.command}{suffix}"
rows: list[_HarnessMenuRow] = [
_HarnessMenuRow("Remove this agent", action="remove"),
_HarnessMenuRow("← Back", action="back"),
]
idx = select(header, [r.label for r in rows], clear_on_exit=True)
if idx < 0 or rows[idx].action == "back":
return
_save_global_config(acp_agents_settings([a for a in agents if a.slug != slug]))
console.print(f" ✓ Removed {agent.name}")
def _manage_hermes_harness() -> None:
"""Run the level-2 loop for Hermes: ensure the CLI is installed.
@@ -11003,14 +11233,26 @@ def _run_configure_harnesses_interactive() -> None:
# / ``kimi provider add`` → ~/.kimi/config.toml), so it dispatches to its
# own drill-in rather than ``_manage_harness_providers``.
_KIMI = "\x00kimi"
# Sentinels for the generic-ACP rows. Each configured agent gets its own row
# (``_ACP_AGENT_PREFIX + slug`` → per-agent remove drill-in); a single
# ``_ACP_ADD`` row jumps straight into the add flow. Not a provider family —
# each ACP agent owns its own auth.
_ACP_ADD = "\x00acp-add"
_ACP_AGENT_PREFIX = "\x00acp-agent:"
families = [ANTHROPIC_FAMILY, OPENAI_FAMILY, PI_SURFACE]
# Status glyph + Rich color per readiness kind: "ready" is a configured,
# launchable harness (green ✓); "missing" is an absent CLI/SDK (red ✗);
# "warn" is installed-but-unconfigured (yellow ✗ — present, not usable
# yet). The glyph leads the status, which sits in a left-aligned column
# right of the names, so every ✓/✗ lines up in a single column.
status_styles = {"ready": ("", "green"), "missing": ("", "red"), "warn": ("", "yellow")}
# yet); "action" is a do-something row (e.g. Add) with no status glyph. The
# glyph leads the status, which sits in a left-aligned column right of the
# names, so every ✓/✗ lines up in a single column.
status_styles = {
"ready": ("", "green"),
"missing": ("", "red"),
"warn": ("", "yellow"),
"action": ("", "cyan"),
}
def _install_hint(command: str) -> str:
# Selection-only tooltip. The command is escaped so a bracketed extra
@@ -11271,6 +11513,34 @@ def _run_configure_harnesses_interactive() -> None:
kimi_spec = harness_install_spec(KIMI_KEY)
kimi_hint = (kimi_spec.install_hint if kimi_spec else None) or "see Kimi Code docs"
rows.append((_KIMI, "Kimi Code", "Not installed", "missing", _install_hint(kimi_hint)))
# Custom ACP agents — the generic `acp` harness driving any user-configured
# ACP-agent command. Each configured agent gets its own overview row
# (select → per-agent remove drill-in) so it sits alongside the built-in
# harnesses, followed by an "Add" row that jumps straight into the add
# flow. Not gated on a binary — each agent owns its own install.
from omnigent.onboarding.acp_auth import acp_config_summary
acp_summary = acp_config_summary()
for agent in acp_summary.agents:
rows.append(
(
_ACP_AGENT_PREFIX + agent.slug,
agent.name,
f"ACP · {agent.command}",
"ready",
"Select to remove this ACP agent.",
)
)
rows.append(
(
_ACP_ADD,
"Add custom ACP agent" if acp_summary.configured else "Custom ACP agent",
"" if acp_summary.configured else "None configured",
"action",
"Add an ACP agent (gemini, qwen, goose, …).",
)
)
return rows
while True:
@@ -11329,6 +11599,10 @@ def _run_configure_harnesses_interactive() -> None:
_manage_opencode_harness()
elif target == _GOOSE:
_manage_goose_harness()
elif target == _ACP_ADD:
_add_acp_agent()
elif isinstance(target, str) and target.startswith(_ACP_AGENT_PREFIX):
_manage_acp_agent(target[len(_ACP_AGENT_PREFIX) :])
elif target == _HERMES:
_manage_hermes_harness()
elif target == _KIRO:
@@ -11633,6 +11907,172 @@ def debug_migrate_accounts_to_oidc(
click.echo("\nDone. Flip OMNIGENT_AUTH_PROVIDER=oidc and restart.\n")
@debug.command("logs")
@click.option(
"--type",
"log_type",
type=click.Choice(["runner", "host-runner", "server", "cli"], case_sensitive=False),
default="runner",
show_default=True,
help="Log category: runner (local CLI runner via omnigent run), "
"host-runner (runner spawned by a host daemon), "
"server (local server), or cli (CLI diagnostics).",
)
@click.option(
"--session",
"session_id",
default=None,
metavar="SESSION_ID",
help="Filter host-runner logs by session id, e.g. conv_abc123. "
"Only applies to --type host-runner. Shows all log files for the "
"session, oldest first.",
)
@click.option(
"--list",
"list_only",
is_flag=True,
default=False,
help="List available log files with size and timestamp instead of showing content.",
)
@click.option(
"--lines",
"-n",
default=50,
show_default=True,
metavar="N",
type=click.IntRange(min=0),
help="Lines to show from the end of the log (0 = entire file). "
"With --session, applied per file.",
)
@click.option(
"--follow",
"-f",
is_flag=True,
default=False,
help="Follow the latest log file in real-time (like tail -f). "
"With --session, follows the most recent file for the session. "
"Not supported on Windows.",
)
def debug_logs(
log_type: str, session_id: str | None, list_only: bool, lines: int, follow: bool
) -> None:
"""Show runner, server, or CLI diagnostic logs.
Prints the tail of the most recent log file for the chosen category.
Use ``--list`` to see all available files, or ``--follow`` to stream
new output as it is written.
Pass ``--session SESSION_ID`` (``--type host-runner`` only) to scope
output to all log files produced for a specific session across relaunches.
\b
Log locations (relative to ~/.omnigent or $OMNIGENT_DATA_DIR):
runner logs/runner/runner-*.log
host-runner logs/host-runner/runner-*.log
server logs/server/*server*.log
cli logs/cli-*.log
\b
Examples:
# Tail the most recent local runner log (default)
omnigent debug logs
# List all local runner log files with sizes
omnigent debug logs --list
# Show host-runner logs for a specific session (across relaunches)
omnigent debug logs --type host-runner --session conv_abc123
# List host-runner log files for a session
omnigent debug logs --type host-runner --session conv_abc123 --list
# Follow the latest server log in real-time
omnigent debug logs --type server --follow
# Show the full latest CLI diagnostics log
omnigent debug logs --type cli -n 0
"""
import re
import subprocess
from omnigent.host.local_server import _local_data_dir
if session_id is not None and log_type != "host-runner":
raise click.UsageError("--session is only supported with --type host-runner")
if follow and IS_WINDOWS:
raise click.UsageError("--follow is not supported on Windows")
data_dir = _local_data_dir()
_log_configs: dict[str, tuple[Path, str]] = {
"runner": (data_dir / "logs" / "runner", "runner-*.log"),
"host-runner": (data_dir / "logs" / "host-runner", "runner-*.log"),
# Covers both server-*.log (omnigent run) and local-server-*.log (daemon).
"server": (data_dir / "logs" / "server", "*server*.log"),
"cli": (data_dir / "logs", "cli-*.log"),
}
log_dir, pattern = _log_configs[log_type]
if not log_dir.exists():
raise click.ClickException(f"No {log_type} logs found — {log_dir} does not exist.")
if session_id is not None:
# Sanitize the same way connect.py does so the glob matches.
slug = re.sub(r"[^\w-]", "", session_id)[:32]
pattern = f"runner-{slug}-*.log"
# Exclude symlinks (e.g. latest-cli.log), sort newest first.
log_files = sorted(
(f for f in log_dir.glob(pattern) if not f.is_symlink()),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if not log_files:
if session_id is not None:
raise click.ClickException(
f"No host-runner logs found for session {session_id!r}. "
"Session ids appear in filenames only for runners launched "
"after this feature was added."
)
raise click.ClickException(f"No {log_type} log files found in {log_dir}.")
if list_only:
header = (
f"host-runner logs for session {session_id!r} in {log_dir}:"
if session_id
else f"{log_type} logs in {log_dir}:"
)
click.echo(header)
for f in log_files:
stat = f.stat()
size_kb = stat.st_size / 1024
mtime = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(stat.st_mtime))
click.echo(f" {mtime} {size_kb:6.1f} KB {f.name}")
return
if follow:
# Follow the most recent file only (tail -f can only track one file).
latest = log_files[0]
click.echo(f"# {latest}", err=True)
subprocess.run(["tail", "-f", str(latest)])
return
if session_id is not None:
# Show all files for the session, oldest first, with separators.
for f in reversed(log_files):
click.echo(f"# {f}", err=True)
content = f.read_text(errors="replace")
if lines > 0:
content = "\n".join(content.splitlines()[-lines:])
click.echo(content)
click.echo()
else:
latest = log_files[0]
click.echo(f"# {latest}", err=True)
content = latest.read_text(errors="replace")
if lines > 0:
content = "\n".join(content.splitlines()[-lines:])
click.echo(content)
def _workspace_mount_probe_matches(candidate: str, probe: httpx.Response) -> bool:
"""Whether a ``/api/2.0/omnigent`` mount probe answered like omnigent.
+41 -2
View File
@@ -226,6 +226,37 @@ def load_databricks_org_id(server_url: str) -> str | None:
DATABRICKS_ORG_ID_HEADER = "X-Databricks-Org-Id"
# Opaque extra request headers for dev/test: a JSON object of header name→value
# in :data:`DATABRICKS_EXTRA_HEADERS_ENV_VAR`. Databricks deployments use it to
# carry request-routing selector headers so a request pins to a specific server
# instance/replica instead of the default one. Folded into
# :func:`databricks_request_headers` below so it travels with every
# client→server connection built through that one helper — a per-call-site
# bearer that skips this helper misses the selectors. Unset in prod.
DATABRICKS_EXTRA_HEADERS_ENV_VAR = "OMNIGENT_DATABRICKS_EXTRA_HEADERS"
def _databricks_extra_headers() -> dict[str, str]:
"""Return the opaque extra request headers when configured, else ``{}``.
Reads :data:`DATABRICKS_EXTRA_HEADERS_ENV_VAR`, a JSON object of header
namevalue. Missing or malformed (unset, not JSON, or not an object)
``{}``, so production and local runs are unaffected.
:returns: A header dict parsed from the env var, or an empty dict.
"""
raw = os.environ.get(DATABRICKS_EXTRA_HEADERS_ENV_VAR, "").strip()
if not raw:
return {}
try:
parsed = json.loads(raw)
except (json.JSONDecodeError, ValueError):
return {}
if not isinstance(parsed, dict):
return {}
return {str(key): str(value) for key, value in parsed.items()}
def databricks_request_headers(
server_url: str, *, bearer_token: str | None = None
) -> dict[str, str]:
@@ -243,12 +274,17 @@ def databricks_request_headers(
Both values are omitted when absent, so single-workspace and
local-unauthenticated callers get ``{}`` and are unaffected.
Also folds in any opaque dev/test headers from
:data:`DATABRICKS_EXTRA_HEADERS_ENV_VAR` (request-routing selectors set by
some Databricks deployments) so every chokepoint that builds headers through
this one helper carries them when set.
:param server_url: The server URL, e.g.
``"https://example.databricks.com/api/2.0/omnigent"``.
:param bearer_token: The workspace bearer token, or ``None`` when the
credential is supplied by a separate mechanism (or there is none).
:returns: A header dict carrying ``Authorization`` and/or
``X-Databricks-Org-Id`` as available, possibly empty.
:returns: A header dict carrying ``Authorization``, ``X-Databricks-Org-Id``,
and/or the configured extra headers as available, possibly empty.
"""
headers: dict[str, str] = {}
if bearer_token:
@@ -256,6 +292,9 @@ def databricks_request_headers(
org_id = load_databricks_org_id(server_url)
if org_id:
headers[DATABRICKS_ORG_ID_HEADER] = org_id
# Opaque dev/test extra headers (request-routing selectors); no-op in prod
# (env unset).
headers.update(_databricks_extra_headers())
return headers
+6 -15
View File
@@ -66,6 +66,7 @@ from omnigent.host.daemon_launch import (
wait_for_host_online,
wait_for_runner_online,
)
from omnigent.native_coding_agents import native_shell_terminal_spec
from omnigent.native_terminal import (
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
)
@@ -526,21 +527,11 @@ def _materialize_codex_agent_spec(
},
# Declare a default shell terminal so the relay advertises the
# ``sys_terminal_*`` family to the wrapped codex (the relay's
# gate is a non-empty ``terminals:`` block on this spec).
# Caller process / no sandbox matches the ``os_env`` stance
# above — the native CLI already runs unsandboxed on the
# user's workspace.
"terminals": {
"shell": {
"command": "bash",
"allow_cwd_override": True,
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {"type": "none"},
},
},
},
# gate is a non-empty ``terminals:`` block on this spec). Its
# command follows the user's ``$SHELL`` (zsh/fish/bash); caller
# process / no sandbox matches the ``os_env`` stance above — the
# native CLI already runs unsandboxed on the user's workspace.
"terminals": native_shell_terminal_spec(),
}
yaml_path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8")
return yaml_path
+2 -111
View File
@@ -36,7 +36,6 @@ from omnigent.inner.codex_executor import (
_clean_codex_env,
_codex_cli_version,
_codex_home_config_source_from_env,
_create_subprocess_exec,
_databricks_codex_auth_command,
_databricks_codex_base_url,
_databricks_codex_config_overrides,
@@ -88,84 +87,6 @@ _TRUSTED_HOOK_STATUSES = frozenset({"trusted", "managed"})
# warning rather than crash startup on an un-trustable hook.
_MIN_POLICY_HOOK_CODEX_VERSION = (0, 129, 0)
# Opt-in flag for the explicit ``--model`` launch flag. Off by default: the
# per-session ``config.toml`` ``model =`` pin (``_pin_codex_config_model``)
# already routes the override today, so the explicit flag is a parallel,
# additive path the operator turns on per deployment. Truthy values mirror
# the ``_TRUE_VALUES`` convention used across the codebase
# (``omnigent/_startup_profile.py``, ``omnigent/cli.py``).
_MODEL_FLAG_ENV_VAR = "OMNIGENT_CODEX_NATIVE_MODEL_FLAG"
_MODEL_FLAG_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
# Timeout for the one-shot ``codex --help`` capability probe. Matches the
# ``codex --version`` probe budget -- a hung help invocation must never block
# app-server startup.
_CODEX_HELP_PROBE_TIMEOUT_SECONDS = 5.0
def _model_flag_enabled(env: dict[str, str] | None = None) -> bool:
"""
Return whether the explicit ``--model`` launch flag is opted in.
The flag is parallel to the always-on ``config.toml`` model pin, so it
defaults OFF: a deployment enables it by setting
:data:`_MODEL_FLAG_ENV_VAR` to a truthy value.
:param env: Environment mapping to inspect; defaults to ``os.environ``.
:returns: ``True`` when the override should also be passed as an
explicit ``--model`` launch flag.
"""
source = os.environ if env is None else env
return source.get(_MODEL_FLAG_ENV_VAR, "").strip().lower() in _MODEL_FLAG_TRUE_VALUES
async def _codex_supports_model_flag(codex_path: str) -> bool:
"""
Detect whether the codex CLI accepts a global ``--model`` flag.
Runs ``codex --help`` and looks for the ``--model`` long option in the
top-level options. Codex exposes ``-m/--model`` as a global flag that
precedes the ``app-server`` subcommand; builds that predate it omit the
option from ``--help``, so the caller skips the flag (passing an unknown
flag would error) and relies on the always-on ``config.toml`` pin.
:param codex_path: Path to the codex CLI, e.g.
``"/usr/local/bin/codex"``.
:returns: ``True`` when ``--model`` appears in ``codex --help`` output;
``False`` when it does not, or the probe cannot be run / times out
(treated conservatively as "unsupported" so the flag is not passed).
"""
try:
proc = await _create_subprocess_exec(
codex_path,
"--help",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
except OSError:
return False
try:
stdout, _ = await asyncio.wait_for(
proc.communicate(), timeout=_CODEX_HELP_PROBE_TIMEOUT_SECONDS
)
except asyncio.TimeoutError:
# A hung ``codex --help`` must not block startup: kill it and treat
# the flag as unsupported (the config.toml pin still carries the model).
with contextlib.suppress(ProcessLookupError):
proc.kill()
with contextlib.suppress(Exception):
await proc.wait()
return False
# Match ``--model`` only as an option *definition* line, not anywhere the
# word appears in help prose. Clap renders options as an indented line
# whose first token is the option, e.g. `` -m, --model <MODEL>`` (or a
# long-only `` --model <MODEL>``). Anchor to the start of such a line
# — optional indent, an optional short alias (``-m, ``), then ``--model``
# at an option boundary. This rejects lookalikes (``--model-provider``)
# and descriptions that merely mention ``--model`` mid-sentence, either of
# which would otherwise pass an unsupported flag to the launch.
help_text = stdout.decode("utf-8", errors="replace")
return re.search(r"^\s*(?:-\S+,\s+)?--model(?=[\s=<]|$)", help_text, re.MULTILINE) is not None
def _format_codex_version(version: tuple[int, int, int] | None) -> str:
"""
@@ -649,30 +570,6 @@ class CodexNativeAppServer:
)
reconcile_codex_native_process_registry()
resolved_listen = self.listen_url or f"unix://{self.socket_path}"
proc_env = {**self.env, "CODEX_HOME": str(self.codex_home)}
# Opt-in, additive to the config.toml ``model =`` pin above: when the
# operator enables the flag and a model is pinned, ALSO pass it
# explicitly. ``-m/--model`` is a codex *global* option, so it must
# precede the ``app-server`` subcommand. A codex build that lacks the
# flag simply doesn't get it (passing an unknown flag would error) --
# the config.toml pin remains the primary route, so the session still
# launches on the right model regardless.
# Read the opt-in from the omnigent server's OWN process environment
# (``os.environ``, the default), NOT ``self.env``: ``self.env`` is the
# cleaned codex spawn env from ``_clean_codex_env``, whose prefix
# allowlist strips ``OMNIGENT_*`` keys -- so the flag would never be
# visible there. The flag is an operator knob for omnigent, not
# something codex itself consumes.
model_global_args: list[str] = []
if (
self.pinned_model
and _model_flag_enabled()
and await _codex_supports_model_flag(self.codex_path)
):
model_global_args = ["--model", self.pinned_model]
# argv[0] carries the inert crash-reap marker (the real binary is passed
# via ``executable=`` below); the model global option rides after it so
# codex still parses it ahead of the ``app-server`` subcommand.
self.process_registry_tag = f"codex-native-{uuid.uuid4().hex}"
tagged_argv0 = (
f"{Path(self.codex_path).name} "
@@ -680,22 +577,16 @@ class CodexNativeAppServer:
)
argv = [
tagged_argv0,
*model_global_args,
"app-server",
"--listen",
resolved_listen,
]
for override in self.config_overrides:
argv.extend(["-c", override])
proc_env = {**self.env, "CODEX_HOME": str(self.codex_home)}
self.process_owner_lock = acquire_codex_native_process_owner_lock()
try:
# Spawn through the module-level ``_create_subprocess_exec``
# indirection (a transparent passthrough to
# ``asyncio.create_subprocess_exec``) so tests can stub the spawn
# by patching that name — patching ``…app_server.asyncio.\
# create_subprocess_exec`` would walk into the real asyncio
# singleton and leak the mock across the process.
self.proc = await _create_subprocess_exec(
self.proc = await asyncio.create_subprocess_exec(
*argv,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
+176 -1
View File
@@ -19,6 +19,21 @@ CODEX_NATIVE_REQUEST_SESSION_ID_ENV_VAR = "HARNESS_CODEX_NATIVE_REQUEST_SESSION_
_STATE_FILE = "state.json"
_STARTUP_ERROR_FILE = "startup_error.json"
# Per-MCP-server startup state mirrored from Codex's
# ``mcpServer/startupStatus/updated`` notifications. Written by the
# forwarder (and by ``wait_for_thread_started`` while it drains startup
# events), read by the executor's first-turn gate and the runner's
# Stop handler.
_MCP_STARTUP_FILE = "mcp_startup.json"
# Startup states mirrored from Codex's ``McpServerStartupState`` enum.
MCP_STARTUP_STARTING = "starting"
MCP_STARTUP_READY = "ready"
MCP_STARTUP_FAILED = "failed"
MCP_STARTUP_CANCELLED = "cancelled"
MCP_STARTUP_STATES = frozenset(
{MCP_STARTUP_STARTING, MCP_STARTUP_READY, MCP_STARTUP_FAILED, MCP_STARTUP_CANCELLED}
)
# Must match ``_CONFIG_FILE`` in ``claude_native_bridge.py`` because
# ``serve-mcp`` reads this filename for the token.
_MCP_CONFIG_FILE = "bridge.json"
@@ -341,7 +356,7 @@ def clear_bridge_state(bridge_dir: Path) -> None:
:param bridge_dir: Native Codex bridge directory.
:returns: None.
"""
for name in (_STATE_FILE, _STARTUP_ERROR_FILE):
for name in (_STATE_FILE, _STARTUP_ERROR_FILE, _MCP_STARTUP_FILE):
try:
(bridge_dir / name).unlink()
except FileNotFoundError:
@@ -392,6 +407,166 @@ def read_bridge_startup_error(bridge_dir: Path) -> str | None:
return message if isinstance(message, str) and message else None
def read_mcp_startup(bridge_dir: Path) -> dict[str, dict[str, str | None]]:
"""
Read the recorded per-MCP-server startup state.
:param bridge_dir: Native Codex bridge directory.
:returns: Mapping of server name to its latest startup record, e.g.
``{"safe": {"status": "starting", "error": None}}``. Empty when
no state has been recorded or the file is unreadable.
"""
path = bridge_dir / _MCP_STARTUP_FILE
if not path.is_file():
return {}
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
servers = raw.get("servers") if isinstance(raw, dict) else None
if not isinstance(servers, dict):
return {}
parsed: dict[str, dict[str, str | None]] = {}
for name, record in servers.items():
if not (isinstance(name, str) and name and isinstance(record, dict)):
continue
status = record.get("status")
if status not in MCP_STARTUP_STATES:
continue
error = record.get("error")
parsed[name] = {
"status": status,
"error": error if isinstance(error, str) and error else None,
}
return parsed
def _write_mcp_startup(bridge_dir: Path, servers: dict[str, dict[str, str | None]]) -> None:
"""
Persist the per-MCP-server startup map atomically (best-effort).
:param bridge_dir: Native Codex bridge directory.
:param servers: Full startup map, e.g.
``{"safe": {"status": "ready", "error": None}}``.
:returns: None.
"""
try:
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
path = bridge_dir / _MCP_STARTUP_FILE
fd, tmp_name = tempfile.mkstemp(prefix=f"{_MCP_STARTUP_FILE}.", dir=str(bridge_dir))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump({"servers": servers}, handle, sort_keys=True)
handle.write("\n")
os.replace(tmp_name, path)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
except OSError:
return # best-effort; surfacing MCP state must never sink startup
def update_mcp_server_startup(
bridge_dir: Path,
name: str,
status: str,
error: str | None = None,
) -> dict[str, dict[str, str | None]]:
"""
Record one Codex MCP-server startup update.
:param bridge_dir: Native Codex bridge directory.
:param name: MCP server name, e.g. ``"storage-console"``.
:param status: One of :data:`MCP_STARTUP_STATES`.
:param error: Failure detail when ``status == "failed"``, e.g.
``"handshaking with MCP server failed"``. ``None`` otherwise.
:returns: The full startup map after the update.
"""
servers = read_mcp_startup(bridge_dir)
servers[name] = {"status": status, "error": error}
_write_mcp_startup(bridge_dir, servers)
return servers
def pending_mcp_servers(servers: dict[str, dict[str, str | None]]) -> list[str]:
"""
Return the MCP servers still reported as ``starting``.
:param servers: Startup map from :func:`read_mcp_startup`.
:returns: Sorted server names whose latest status is ``starting``.
"""
return sorted(
name for name, record in servers.items() if record.get("status") == MCP_STARTUP_STARTING
)
def cancel_pending_mcp_startup(bridge_dir: Path) -> list[str]:
"""
Mark every still-``starting`` MCP server as ``cancelled``.
Used by the Stop path so the executor's first-turn gate unblocks
immediately, even when Codex's own ``cancelled`` notifications are
delayed or lost.
:param bridge_dir: Native Codex bridge directory.
:returns: Sorted names of the servers that were flipped, e.g.
``["storage-console"]``. Empty when nothing was pending.
"""
servers = read_mcp_startup(bridge_dir)
pending = pending_mcp_servers(servers)
if not pending:
return []
for name in pending:
servers[name] = {"status": MCP_STARTUP_CANCELLED, "error": servers[name].get("error")}
_write_mcp_startup(bridge_dir, servers)
return pending
def settle_pending_mcp_startup(bridge_dir: Path) -> tuple[dict[str, dict[str, str | None]], bool]:
"""
Drop every still-``starting`` MCP server from the recorded map.
Codex delivers per-server terminal states (ready/failed) only to the
connection that owns the thread never to Omnigent's observer
connection so when a settle signal arrives (the thread went idle
after a turn, or the startup window elapsed) the round is known to be
over but the per-server outcomes are not. Unresolved entries are
removed rather than guessed; locally-known terminal states
(``cancelled`` from a Stop) are preserved.
:param bridge_dir: Native Codex bridge directory.
:returns: ``(map_after, changed)`` the settled map and whether any
entry was dropped.
"""
# The read→write below is not locked across processes: a runner Stop
# can flip an entry to ``cancelled`` in between, and this write drops
# it. Cosmetic only — both outcomes end the round, and the Stop path
# publishes its cancelled map independently.
servers = read_mcp_startup(bridge_dir)
pending = pending_mcp_servers(servers)
if not pending:
return servers, False
for name in pending:
servers.pop(name, None)
_write_mcp_startup(bridge_dir, servers)
return servers, True
def mcp_startup_waiting_detail(servers: dict[str, dict[str, str | None]]) -> str | None:
"""
Describe the MCP servers a startup wait is still blocked on.
:param servers: Startup map from :func:`read_mcp_startup`.
:returns: Text naming the pending servers, e.g.
``"MCP startup still waiting on storage-console"``, or ``None``
when nothing is pending.
"""
pending = pending_mcp_servers(servers)
if not pending:
return None
return f"MCP startup still waiting on {', '.join(pending)}"
def read_bridge_state(bridge_dir: Path) -> CodexNativeBridgeState | None:
"""
Read shared native Codex bridge state.
+326
View File
@@ -34,12 +34,18 @@ from omnigent.codex_native_app_server import (
)
from omnigent.codex_native_bridge import (
CODEX_NATIVE_BRIDGE_ID_LABEL_KEY,
MCP_STARTUP_STARTING,
MCP_STARTUP_STATES,
CodexNativeBridgeState,
clear_active_turn_id_if_matches,
codex_home_for_bridge_dir,
pending_mcp_servers,
read_bridge_state,
read_codex_config_model,
read_mcp_startup,
settle_pending_mcp_startup,
update_active_turn_id,
update_mcp_server_startup,
update_thread_id,
write_bridge_state,
)
@@ -112,6 +118,31 @@ _CODEX_ELICITATION_CONNECT_TIMEOUT_SECONDS = 30.0
_CODEX_ELICITATION_RETRY_INITIAL_BACKOFF_SECONDS = 1.0
_CODEX_ELICITATION_RETRY_MAX_BACKOFF_SECONDS = 30.0
_CODEX_MCP_ELICITATION_REQUEST_METHOD = "mcpServer/elicitation/request"
# Per-server MCP startup progress (issue #2058). Codex runs an MCP
# startup round when a thread starts, but delivers the per-server
# ``mcpServer/startupStatus/updated`` edges ONLY to the connection that
# owns the thread (the TUI) — verified against codex 0.142.5 — so this
# observer connection cannot passively mirror them. Instead the round is
# SYNTHESIZED: at forwarder start the config-declared servers are
# recorded as ``starting`` (true — codex boots them all at thread start)
# in the bridge dir and posted to Omnigent as ``external_mcp_startup``; the
# round is settled (unresolved entries dropped) when the thread goes
# idle after a turn — codex defers turn execution until startup ends, so
# an idle edge proves the round is over — or when the config-derived
# startup window elapses. ``cancelled`` states are recorded locally by
# the Stop path. The notification handler is kept as a zero-cost path
# for any delivery codex broadens later (it fully supersedes synthesis
# when edges do arrive).
_CODEX_MCP_STARTUP_STATUS_METHOD = "mcpServer/startupStatus/updated"
_CODEX_THREAD_STATUS_CHANGED_METHOD = "thread/status/changed"
_EXTERNAL_MCP_STARTUP_TYPE = "external_mcp_startup"
# Codex bounds each MCP server's spawn+handshake by its per-server
# ``startup_timeout_sec`` (codex default 10s); the round cannot outlive
# the slowest server's budget. The synthesis settle timer mirrors that
# bound, with floor/grace/cap keeping a misconfigured value sane.
_MCP_STARTUP_DEFAULT_TIMEOUT_SECONDS = 10.0
_MCP_STARTUP_SETTLE_GRACE_SECONDS = 15.0
_MCP_STARTUP_SETTLE_MAX_SECONDS = 240.0
_CODEX_TOOL_REQUEST_USER_INPUT_METHOD = "item/tool/requestUserInput"
_CODEX_COMMAND_EXECUTION_REQUEST_APPROVAL_METHOD = "item/commandExecution/requestApproval"
_CODEX_FILE_CHANGE_REQUEST_APPROVAL_METHOD = "item/fileChange/requestApproval"
@@ -1601,6 +1632,14 @@ async def supervise_forwarder(
# outage or restart). Runs before live forwarding begins, so no
# other writer races the dead-letter files (#1579).
await _replay_dead_letters_on_startup(ap_client, bridge_dir)
# Synthesize the thread's MCP startup round (see the comment on
# _CODEX_MCP_STARTUP_STATUS_METHOD): the fresh-launch forwarder
# starts right at thread creation, which is when codex boots its
# configured MCP servers. Skipped when the bridge already carries
# round state (forwarder reconnect mid-session).
mcp_settle_timer = await _seed_mcp_startup_round(
ap_client, session_id=session_id, bridge_dir=bridge_dir
)
target = _ForwarderTarget(
session_id=session_id,
thread_id=thread_id,
@@ -1686,6 +1725,10 @@ async def supervise_forwarder(
except Exception: # noqa: BLE001 - keep the long-lived mirror alive.
_logger.warning("Codex forwarder event handling failed", exc_info=True)
finally:
if mcp_settle_timer is not None:
mcp_settle_timer.cancel()
with contextlib.suppress(asyncio.CancelledError):
await mcp_settle_timer
await target.delta_coalescer.close()
await target.usage_coalescer.close()
await target.elicitation_tracker.close()
@@ -2254,6 +2297,39 @@ async def _handle_event(
_parent_thread_id_from_started_event(event),
)
return
if method == _CODEX_MCP_STARTUP_STATUS_METHOD:
# MCP startup is bridge-level state, surfaced on the parent
# session. The notification's ``threadId`` is nullable; a child
# thread's startup (different id) is not mirrored.
event_thread_id = _thread_id_from_params(params)
if (
event_thread_id is None
or expected_thread_id is None
or event_thread_id == expected_thread_id
):
parent_session_id = (
forwarder_state.parent_session_id
if forwarder_state is not None and forwarder_state.parent_session_id is not None
else session_id
)
await _handle_mcp_startup_status(
client,
session_id=parent_session_id,
bridge_dir=bridge_dir,
params=params,
)
return
if _is_thread_idle_status_event(method, params) and _thread_id_from_params(params) in {
None,
expected_thread_id,
}:
# A completed turn proves MCP startup settled (codex defers turn
# execution until the round ends) — resolve the synthesized round.
# Not an exclusive handler: idle status also feeds the subscribe
# release below, so fall through.
await _settle_mcp_startup(
client, session_id=session_id, bridge_dir=bridge_dir, reason="thread went idle"
)
# Resolve routing: parent thread, known child thread, or stale/ignored.
route_session_id, is_child = _resolve_event_session(
params, method, expected_thread_id, forwarder_state, fallback_session_id=session_id
@@ -2989,6 +3065,256 @@ def _handle_turn_diff_updated(
forwarder_state.note_turn_diff(turn_id, diff if isinstance(diff, str) else "")
async def _handle_mcp_startup_status(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
params: dict[str, Any],
) -> None:
"""
Mirror one Codex MCP-server startup update.
Records the update into the bridge dir (the Stop path and turn-error
text read it) and republishes the full per-server map to Omnigent so the
web session shows startup progress. In practice codex delivers these
edges only to the thread-owning connection (see the comment on
:data:`_CODEX_MCP_STARTUP_STATUS_METHOD`); when they do arrive they
carry real terminal states and supersede the synthesized round.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param params: Codex ``mcpServer/startupStatus/updated`` params, e.g.
``{"name": "safe", "status": "failed", "error": "..."}``.
:returns: None.
"""
name = params.get("name")
status = params.get("status")
if not (isinstance(name, str) and name and status in MCP_STARTUP_STATES):
_logger.info("Codex forwarder ignored malformed MCP startup update: %r", params)
return
error = params.get("error")
servers = update_mcp_server_startup(
bridge_dir,
name,
status,
error=error if isinstance(error, str) and error else None,
)
await _post_mcp_startup(client, session_id, servers)
def _expected_mcp_servers_from_config(bridge_dir: Path) -> list[str]:
"""
Read the enabled MCP server names from the session's Codex config.
The per-session ``config.toml`` (private ``CODEX_HOME``) is what the
app-server loads, so its ``[mcp_servers.*]`` tables are exactly the
servers codex boots at thread start including the injected
``omnigent`` relay server. Codex-internal servers that are not
config-declared (e.g. ``codex_apps``) are not visible here and are
simply absent from the synthesized round.
:param bridge_dir: Native Codex bridge directory.
:returns: Sorted enabled server names, e.g. ``["omnigent", "safe"]``.
Empty when the config is missing or unparsable.
"""
import tomllib
config_path = codex_home_for_bridge_dir(bridge_dir) / "config.toml"
try:
config = tomllib.loads(config_path.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError):
return []
servers = config.get("mcp_servers")
if not isinstance(servers, dict):
return []
return sorted(
name
for name, table in servers.items()
if isinstance(name, str)
and name
and isinstance(table, dict)
and table.get("enabled") is not False
)
def _mcp_startup_settle_timeout_seconds(bridge_dir: Path) -> float:
"""
Derive the synthesized round's settle window from the session config.
Codex bounds each server's spawn+handshake by its per-server
``startup_timeout_sec`` (default
:data:`_MCP_STARTUP_DEFAULT_TIMEOUT_SECONDS`), so the round cannot
outlive the slowest server's budget; a grace period absorbs spawn
overhead and the cap keeps a misconfigured budget from pinning the
band for many minutes.
:param bridge_dir: Native Codex bridge directory.
:returns: Settle timeout in seconds, e.g. ``135.0`` for a config whose
slowest server declares ``startup_timeout_sec = 120``.
"""
import tomllib
slowest = _MCP_STARTUP_DEFAULT_TIMEOUT_SECONDS
config_path = codex_home_for_bridge_dir(bridge_dir) / "config.toml"
try:
config = tomllib.loads(config_path.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError):
config = {}
servers = config.get("mcp_servers")
if isinstance(servers, dict):
for table in servers.values():
# Same enabled filter as _expected_mcp_servers_from_config:
# codex never boots a disabled server, so its budget must not
# stretch the window for a round it is not part of.
if not isinstance(table, dict) or table.get("enabled") is False:
continue
timeout = table.get("startup_timeout_sec")
if isinstance(timeout, (int, float)) and timeout > slowest:
slowest = float(timeout)
return min(slowest + _MCP_STARTUP_SETTLE_GRACE_SECONDS, _MCP_STARTUP_SETTLE_MAX_SECONDS)
def _arm_mcp_settle_timer(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
) -> asyncio.Task[None]:
"""
Arm the bounded settle window for an in-flight MCP startup round.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:returns: The settle-timer task.
"""
timeout = _mcp_startup_settle_timeout_seconds(bridge_dir)
async def settle_after_window() -> None:
"""Settle the synthesized round once the startup window elapses."""
await _sleep(timeout)
await _settle_mcp_startup(
client, session_id=session_id, bridge_dir=bridge_dir, reason="startup window elapsed"
)
return asyncio.create_task(settle_after_window(), name="codex-native-mcp-settle")
async def _seed_mcp_startup_round(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
) -> asyncio.Task[None] | None:
"""
Record the config-declared MCP servers as ``starting`` and post them.
Seeds once per app-server launch: ``clear_bridge_state`` wipes the
recorded map before each launch, and an existing map means a
forwarder reconnect mid-session reseeding then would flash a false
"starting" band for servers that finished booting long ago. A
reconnect that finds the round still pending does re-arm the settle
window, though: the previous forwarder's timer died with it, and
without a replacement a missed idle edge would leave the band stuck
on "starting" for the rest of the session.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:returns: The armed settle-timer task, or ``None`` when the recorded
round has already settled.
"""
existing = read_mcp_startup(bridge_dir)
if existing:
if not pending_mcp_servers(existing):
return None
_logger.info("Codex MCP startup round still pending after reconnect; re-arming settle")
return _arm_mcp_settle_timer(client, session_id=session_id, bridge_dir=bridge_dir)
expected = _expected_mcp_servers_from_config(bridge_dir)
if not expected:
return None
servers: dict[str, dict[str, str | None]] = {}
for name in expected:
servers = update_mcp_server_startup(bridge_dir, name, MCP_STARTUP_STARTING)
_logger.info("Codex MCP startup round synthesized: %s", ", ".join(expected))
await _post_mcp_startup(client, session_id, servers)
return _arm_mcp_settle_timer(client, session_id=session_id, bridge_dir=bridge_dir)
async def _settle_mcp_startup(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
reason: str,
) -> None:
"""
Settle the synthesized MCP startup round, if any of it is unresolved.
Drops still-``starting`` entries from the bridge map (their real
terminal states are only ever delivered to the thread-owning
connection) and posts the settled map so the web band clears.
Locally-recorded terminal states ``cancelled`` from a Stop are
preserved. Idempotent: a fully settled map is left untouched.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory.
:param reason: Settle trigger for logs, e.g. ``"thread went idle"``.
:returns: None.
"""
servers, changed = settle_pending_mcp_startup(bridge_dir)
if not changed:
return
_logger.info("Codex MCP startup round settled (%s)", reason)
await _post_mcp_startup(client, session_id, servers)
def _is_thread_idle_status_event(method: str, params: dict[str, Any]) -> bool:
"""
Return whether an event reports the thread going idle.
Codex defers turn execution until MCP startup settles, so a thread
reaching ``idle`` after a turn proves the startup round is over. This
is one of the few notifications codex broadcasts to non-owning
connections, making it the natural live settle signal for the
synthesized round.
:param method: Codex method value, e.g. ``"thread/status/changed"``.
:param params: Codex notification params.
:returns: ``True`` for an idle ``thread/status/changed``.
"""
if method != _CODEX_THREAD_STATUS_CHANGED_METHOD:
return False
status = params.get("status")
return isinstance(status, dict) and status.get("type") == "idle"
async def _post_mcp_startup(
client: httpx.AsyncClient,
session_id: str,
servers: dict[str, dict[str, str | None]],
) -> None:
"""
Post the current per-MCP-server startup map to Omnigent.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param servers: Full startup map, e.g.
``{"safe": {"status": "starting", "error": None}}``.
:returns: None.
"""
response = await _post_session_event(
client,
session_id,
event_type=_EXTERNAL_MCP_STARTUP_TYPE,
data={"servers": servers},
)
_log_failed_session_event_post(_EXTERNAL_MCP_STARTUP_TYPE, response)
def _is_codex_elicitation_request(event: CodexMessage) -> bool:
"""
Return whether an app-server frame asks this client for input.
+7 -5
View File
@@ -152,25 +152,27 @@ def _main_evaluate_policy(argv: list[str]) -> int:
# The session is governed (bridge state + ap_server_url) and we have a
# policy-relevant event: from here a failure to obtain a usable verdict
# fails CLOSED for the tool-call gate (see ``fail_closed_hook_output``).
def _fail_closed() -> int:
out = fail_closed_hook_output(hook_event)
reauth = policy_hook_reauth(ap_server_url, headers)
def _fail_closed(detail: str | None = None) -> int:
out = fail_closed_hook_output(hook_event, detail)
if out is not None:
sys.stdout.write(json.dumps(out))
return 0
session_component = urllib.parse.quote(session_id, safe="")
url = f"{ap_server_url.rstrip('/')}/v1/sessions/{session_component}/policies/evaluate"
resp = post_evaluate_with_retry(
resp, api_error = post_evaluate_with_retry(
url,
headers,
eval_request,
_EVALUATE_POLICY_TIMEOUT_S,
"codex evaluate-policy hook",
# Re-mint the baked one-shot token if it lapses mid-session.
reauth=policy_hook_reauth(ap_server_url, headers),
reauth=reauth,
)
if resp is None:
return _fail_closed()
return _fail_closed(api_error or reauth.failure_reason)
if not resp.content:
print("omnigent codex evaluate-policy hook: empty Omnigent response", file=sys.stderr)
return _fail_closed()
+53
View File
@@ -0,0 +1,53 @@
"""Read Omnigent's user and project configuration."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import yaml
_CONFIG_HOME_ENV_VAR = "OMNIGENT_CONFIG_HOME"
_GLOBAL_CONFIG_PATH = Path.home() / ".omnigent" / "config.yaml"
_LOCAL_CONFIG_RELPATH = Path(".omnigent") / "config.yaml"
def global_config_path(default_path: Path | None = None) -> Path:
"""Return the effective user-level config path."""
if config_home := os.environ.get(_CONFIG_HOME_ENV_VAR):
return Path(config_home) / "config.yaml"
return default_path or _GLOBAL_CONFIG_PATH
def load_global_config(path: Path | None = None) -> dict[str, Any]: # type: ignore[explicit-any]
"""Load the user-level config, returning an empty mapping when absent."""
resolved_path = path or global_config_path()
if not resolved_path.exists():
return {}
with resolved_path.open() as config_file:
raw: dict[str, Any] = yaml.safe_load(config_file) or {} # type: ignore[explicit-any]
return raw
def load_local_config(path: Path | None = None) -> dict[str, Any]: # type: ignore[explicit-any]
"""Load the project-level config, returning an empty mapping when absent."""
resolved_path = path or Path.cwd() / _LOCAL_CONFIG_RELPATH
if not resolved_path.exists():
return {}
with resolved_path.open() as config_file:
raw: dict[str, Any] = yaml.safe_load(config_file) or {} # type: ignore[explicit-any]
return raw
def load_effective_config() -> dict[str, Any]: # type: ignore[explicit-any]
"""Merge user and project config, with project values taking precedence."""
return {**load_global_config(), **load_local_config()}
__all__ = [
"global_config_path",
"load_effective_config",
"load_global_config",
"load_local_config",
]
+6 -259
View File
@@ -1,67 +1,20 @@
"""Advisor verdict contract for per-turn brain-model selection.
"""Cost-control label namespace shared between the server and runner.
THE interface between the per-turn cost advisor
(:mod:`omnigent.runner.cost_advisor`) and the session label that
records what it decided: the advisor serializes an
:class:`AdvisorVerdict` into ONE conversation label
(:data:`COST_CONTROL_PLAN_LABEL`, JSON-encoded) and readers parse it
back with :func:`parse_verdict`. Anything that needs to agree on "what
did the advisor decide for this turn's brain" goes through this module
never through ad-hoc dicts.
The advisor v3 contract (this module): a per-user-turn LLM judge picks
ONE model for the ORCHESTRATOR'S OWN BRAIN, sized to the turn's
difficulty (difficult coding expensive tier, medium knowledge work
medium, trivial cheap). The verdict names a single tier + a single
concrete model drawn from that tier's configured list. The orchestrator
brain still freely decides how many sub-agents to spawn, which workers,
and which worker models that is correctness (the model-family guard),
not cost, and the advisor never touches it.
What retired with v3: the multi-entry tier PARTITION of v2 (a turn now
runs on ONE brain model; a mixed-difficulty query takes the MAX tier
its parts need), the ``sys_session_send`` dispatch guard
(``cost_guard``), and the advise-mode divergence telemetry (nothing to
diverge from once the verdict targets the brain, not dispatches).
This module is pure: no I/O, no ambient clock (callers pass the turn
anchor in); its only project import is the shared model-spelling
canonicalizer from :mod:`omnigent.model_override`, so tier ranking and
the brain-application layer agree on which spellings name the same
model.
Defines the label-key prefix that the server reserves for policy-owned
cost-control metadata, and the helper that identifies which keys in a
client-supplied label map fall under that namespace.
"""
from __future__ import annotations
import json
from collections.abc import Mapping
from dataclasses import dataclass
# Label-key prefix of the policy-owned cost-control namespace. Labels
# under it are advisor/runner-written telemetry; the server rejects them
# in client-supplied label writes (see ``update_session`` /
# under it are runner-written telemetry; the server rejects them in
# client-supplied label writes (see ``update_session`` /
# ``create_session`` in :mod:`omnigent.server.routes.sessions`).
COST_CONTROL_LABEL_NAMESPACE = "cost_control."
# Conversation label carrying the JSON-encoded advisor verdict for the
# session's most recent advised turn.
COST_CONTROL_PLAN_LABEL = "cost_control.plan"
# Schema version serialized into the label. v3 is the single-verdict
# brain-model shape; v1 (per-worker draft) and v2 (tier partition) never
# carry meaning here. parse_verdict version-gates strictly on v3 and
# tolerates a legacy v2 label in an old session by returning None rather
# than crashing the reader.
PLAN_VERSION = 3
# Tier names in ascending cost order: cheap < medium < expensive.
TIER_ORDER: tuple[str, ...] = ("cheap", "medium", "expensive")
# Advisor enforcement modes: "optimize" applies the verdict to the
# brain; "advise" runs the judge in shadow (records the verdict, leaves
# the brain model unchanged).
ADVISOR_MODES: tuple[str, ...] = ("advise", "optimize")
def reserved_cost_control_keys(labels: Mapping[str, str]) -> tuple[str, ...]:
"""
@@ -74,209 +27,3 @@ def reserved_cost_control_keys(labels: Mapping[str, str]) -> tuple[str, ...]:
mapping touches no reserved keys.
"""
return tuple(key for key in labels if key.startswith(COST_CONTROL_LABEL_NAMESPACE))
def tier_rank(tier: str) -> int:
"""
Return the cost rank of a tier name (lower = cheaper).
:param tier: A tier name from :data:`TIER_ORDER`, e.g. ``"cheap"``.
:returns: The tier's index in :data:`TIER_ORDER`, e.g. ``0``.
:raises ValueError: When *tier* is not a known tier name an
unknown tier is a configuration error that must fail loud, not
silently rank as cheapest or priciest.
"""
try:
return TIER_ORDER.index(tier)
except ValueError:
raise ValueError(f"unknown tier {tier!r}; expected one of {TIER_ORDER}") from None
@dataclass(frozen=True, kw_only=True)
class AdvisorVerdict:
"""
A per-turn brain-model selection produced by the cost advisor.
The advisor picks ONE model (drawn from one tier's configured list)
for the orchestrator's OWN brain this turn, sized to the turn's
difficulty. ``applied`` records whether the brain actually ran on
that model: ``True`` in optimize mode (the override took effect),
``False`` in advise mode (shadow telemetry, brain unchanged) or when
a user model pin beat the advisor.
:param version: Serialization schema version, e.g. ``3``
(:data:`PLAN_VERSION`).
:param tier: The difficulty tier the judge assigned the turn, one of
:data:`TIER_ORDER`, e.g. ``"expensive"``.
:param model: The concrete brain model the judge chose from
``tier``'s configured list, e.g.
``"databricks-claude-opus-4-8"``.
:param applied: ``True`` when the brain ran on :attr:`model` this
turn (optimize mode, no user pin); ``False`` when the verdict was
recorded but not applied (advise mode, or a user model pin won).
:param rationale: One-sentence judge explanation, surfaced in the
UI and (optimize mode) in the in-turn system note. The judge
always produces a string (:mod:`omnigent.runner.cost_judge`
substitutes a fallback when the model returns none); ``None`` is
reserved for the serialize/parse round-trip's degenerate case,
where even an empty rationale would not fit the labels column.
:param turn_anchor: Caller-supplied anchor tying the verdict to the
turn that produced it (an item id or ISO timestamp), e.g.
``"2026-06-10T12:00:00+00:00"``. Callers sample the clock; this
module never does.
"""
version: int = PLAN_VERSION
tier: str
model: str
applied: bool
rationale: str | None
turn_anchor: str
# Conversation labels persist into a varchar(256) column; values longer
# than this are rejected wholesale by Postgres.
_LABEL_VALUE_MAX_LEN = 256
# Suffix marking a rationale trimmed to fit the labels column.
_TRIM_MARKER = "..."
def verdict_to_label_value(verdict: AdvisorVerdict) -> str:
"""
Serialize a verdict into the :data:`COST_CONTROL_PLAN_LABEL` value.
Long judge rationales are trimmed so the value fits the labels
column (an oversized value fails the whole write, and the verdict
then never surfaces). The full rationale still reaches the UI via the
``routing_decision`` transcript item.
Trimming measures SERIALIZED length, not raw character count.
:func:`json.dumps` defaults to ``ensure_ascii=True``, so a non-ASCII
char escapes to ``\\uXXXX`` (6 chars) and a quote/backslash to 2;
counting raw chars dropped a short non-ASCII rationale wholesale (to
``null``) even with column budget to spare. The trim keeps the
longest rationale prefix that fits, then appends
:data:`_TRIM_MARKER`; only the degenerate case (the other fields
alone overflow the column) yields a ``null`` rationale.
:param verdict: The verdict to serialize.
:returns: Compact JSON, e.g. ``'{"applied":true,"model":
"databricks-claude-opus-4-8","rationale":"...","tier":
"expensive","turn_anchor":"...","version":3}'``, at most
:data:`_LABEL_VALUE_MAX_LEN` characters.
"""
payload = {
"version": verdict.version,
"tier": verdict.tier,
"model": verdict.model,
"applied": verdict.applied,
"rationale": verdict.rationale,
"turn_anchor": verdict.turn_anchor,
}
serialized = json.dumps(payload, separators=(",", ":"), sort_keys=True)
if len(serialized) <= _LABEL_VALUE_MAX_LEN or not verdict.rationale:
return serialized
# Serialized chars left for the rationale's escaped CONTENT, after the
# rest of the object and the trim marker take their share. base_len is
# measured with an empty rationale, so it already counts every other
# field's escaping plus the rationale value's two surrounding quotes.
base_payload = dict(payload)
base_payload["rationale"] = ""
base_len = len(json.dumps(base_payload, separators=(",", ":"), sort_keys=True))
budget = _LABEL_VALUE_MAX_LEN - base_len - len(_TRIM_MARKER)
kept = ""
if budget > 0:
# Largest prefix whose escaped content fits the budget. Escaped
# length is monotonic in prefix length, so binary-search it.
# ``json.dumps(s)`` wraps the value in quotes, hence the ``- 2``.
lo, hi = 0, len(verdict.rationale)
while lo < hi:
mid = (lo + hi + 1) // 2
if len(json.dumps(verdict.rationale[:mid])) - 2 <= budget:
lo = mid
else:
hi = mid - 1
kept = verdict.rationale[:lo]
payload["rationale"] = (kept + _TRIM_MARKER) if kept else None
return json.dumps(payload, separators=(",", ":"), sort_keys=True)
def parse_verdict(labels: Mapping[str, str]) -> AdvisorVerdict | None:
"""
Parse an :class:`AdvisorVerdict` out of a conversation-label mapping.
Version-gates strictly on v3. A legacy v2 label (a tier partition
written by an older runner into a session that predates this build)
is TOLERATED: it parses to ``None`` instead of raising, so old
sessions keep loading the advisor simply has no v3 verdict to
surface for them. Any other malformed v3 label fails loud, since a
corrupt current-version label is a real bug, not legacy data.
:param labels: The conversation's labels, e.g.
``{"cost_control.plan": '{"version": 3, ...}'}``.
:returns: The parsed v3 verdict; ``None`` when the label is absent
(no advised turn yet) or is a tolerated legacy v2 label. A parsed
verdict's ``rationale`` is ``None`` when the writer had to drop it
to fit the column (see :func:`verdict_to_label_value`).
:raises ValueError: When a v3-shaped label is malformed (bad JSON,
wrong field types, unknown tier). A ``null`` rationale is NOT
malformed: the writer emits it in the degenerate case, so it
round-trips rather than raising.
"""
raw = labels.get(COST_CONTROL_PLAN_LABEL)
if raw is None:
return None
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"{COST_CONTROL_PLAN_LABEL} label is not valid JSON: {exc}") from exc
if not isinstance(payload, dict):
raise ValueError(f"{COST_CONTROL_PLAN_LABEL} label must be a JSON object")
version = payload.get("version")
if version != PLAN_VERSION:
# Legacy v2 (tier partition) / v1 in an old session: tolerate by
# ignoring rather than crashing the reader. Only the current
# schema is parsed; older shapes carry no v3 verdict.
return None
tier = payload.get("tier")
if not isinstance(tier, str) or tier not in TIER_ORDER:
raise ValueError(
f"{COST_CONTROL_PLAN_LABEL} verdict has tier {tier!r}; expected one of {TIER_ORDER}"
)
model = payload.get("model")
if not isinstance(model, str) or not model:
raise ValueError(f"{COST_CONTROL_PLAN_LABEL} verdict needs a non-empty string model")
applied = payload.get("applied")
if not isinstance(applied, bool):
raise ValueError(f"{COST_CONTROL_PLAN_LABEL} verdict needs a boolean applied field")
rationale = payload.get("rationale")
if rationale is not None and not isinstance(rationale, str):
raise ValueError(
f"{COST_CONTROL_PLAN_LABEL} verdict needs a string or null rationale field"
)
turn_anchor = payload.get("turn_anchor")
if not isinstance(turn_anchor, str):
raise ValueError(f"{COST_CONTROL_PLAN_LABEL} verdict needs a string turn_anchor field")
return AdvisorVerdict(
version=PLAN_VERSION,
tier=tier,
model=model,
applied=applied,
rationale=rationale,
turn_anchor=turn_anchor,
)
def describe_verdict(verdict: AdvisorVerdict) -> str:
"""
Render a verdict as the one-line summary used in notes and logs.
:param verdict: The verdict to describe.
:returns: Summary text, e.g.
``"databricks-claude-opus-4-8 (expensive)"``.
"""
return f"{verdict.model} ({verdict.tier})"
+4 -11
View File
@@ -41,6 +41,7 @@ from omnigent.host.daemon_launch import (
wait_for_host_online,
wait_for_runner_online,
)
from omnigent.native_coding_agents import native_shell_terminal_spec
from omnigent.native_terminal import (
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
)
@@ -334,17 +335,9 @@ def _materialize_cursor_agent_spec(tmpdir: Path) -> Path:
"cwd": ".",
"sandbox": {"type": "none"},
},
"terminals": {
"shell": {
"command": "bash",
"allow_cwd_override": True,
"os_env": {
"type": "caller_process",
"cwd": ".",
"sandbox": {"type": "none"},
},
},
},
# Default shell terminal for the web-UI "+ New shell" affordance;
# its command follows the user's ``$SHELL`` (zsh/fish/bash).
"terminals": native_shell_terminal_spec(),
}
yaml_path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8")
return yaml_path
+6
View File
@@ -1,6 +1,7 @@
"""Database package — SQLAlchemy models and Alembic migrations."""
from omnigent.db.db_models import (
DEFAULT_WORKSPACE_ID,
Base,
SqlAgent,
SqlConversation,
@@ -8,9 +9,12 @@ from omnigent.db.db_models import (
SqlFile,
SqlSessionPermission,
SqlUser,
current_workspace_id,
workspace_scope,
)
__all__ = [
"DEFAULT_WORKSPACE_ID",
"Base",
"SqlAgent",
"SqlConversation",
@@ -18,4 +22,6 @@ __all__ = [
"SqlFile",
"SqlSessionPermission",
"SqlUser",
"current_workspace_id",
"workspace_scope",
]
+110
View File
@@ -0,0 +1,110 @@
"""Transparent client-side compression for opaque text columns.
A handful of columns hold machine-generated JSON or free text that is never
queried in SQL or read by hand per-conversation ``session_state`` /
``session_usage``, native ``terminal_launch_args``, comment bodies/anchors, and
agent descriptions. Compressing them on the client gives a uniform on-disk size
across every backend: MySQL's InnoDB does not compress ``TEXT``/``BLOB`` by
default and SQLite never does, so relying on per-backend storage compression
would leave those two uncompressed while PostgreSQL (TOAST) compresses.
Stored layout (bytes), chosen so post-migration and legacy rows coexist without
a backfill:
* **New values are framed:** a leading NUL sentinel (``0x00``) followed by a
one-byte codec id and the payload. Valid text in these columns can never
start with NUL PostgreSQL forbids NUL in ``text`` outright, and the JSON
they hold always leads with ``{``/``[``/``"`` — so the sentinel is an
unambiguous "this row is framed" marker.
* **Legacy values are unframed UTF-8 text** (written while the column was
``TEXT``). They are detected by the absent sentinel or, under SQLite's
dynamic typing, by arriving as ``str`` and returned unchanged. Each such
row re-frames itself the next time it is written.
"""
from __future__ import annotations
import zstandard
from sqlalchemy import LargeBinary
from sqlalchemy.types import TypeDecorator
# Leading byte marking a framed (post-migration) value. Legacy text never
# begins with NUL, so its presence unambiguously distinguishes the two formats.
_SENTINEL = 0x00
# Codec ids, stored as the byte after the sentinel.
_CODEC_RAW = 0x00 # payload stored uncompressed (below the size threshold)
_CODEC_ZSTD = 0x01 # payload compressed with zstd
# Below this many UTF-8 bytes, zstd's frame overhead outweighs the gain, so the
# payload is framed but left uncompressed.
_MIN_COMPRESS_BYTES = 64
# Write-once / read-rarely columns, so favour ratio over speed. The payloads are
# small enough that the window size a high level implies never fills.
_LEVEL = 19
def encode(text: str | None) -> bytes | None:
"""Frame *text* for storage.
:param text: The plaintext to store, or ``None``.
:returns: ``sentinel + codec + payload`` bytes, or ``None`` when *text* is
``None``.
"""
if text is None:
return None
raw = text.encode("utf-8")
if len(raw) < _MIN_COMPRESS_BYTES:
return bytes((_SENTINEL, _CODEC_RAW)) + raw
packed = zstandard.ZstdCompressor(level=_LEVEL).compress(raw)
return bytes((_SENTINEL, _CODEC_ZSTD)) + packed
def decode(value: bytes | str | memoryview | None) -> str | None:
"""Inverse of :func:`encode`; also passes through legacy unframed text.
:param value: The stored column value: framed bytes, legacy UTF-8 bytes,
a legacy ``str`` (SQLite dynamic typing), a ``memoryview`` (some
drivers), or ``None``.
:returns: The decoded plaintext, or ``None`` when *value* is ``None``.
"""
if value is None:
return None
# SQLite is dynamically typed: a value written before the column became a
# BLOB comes back as ``str``. It is legacy plaintext, unchanged.
if isinstance(value, str):
return value
if isinstance(value, memoryview):
value = value.tobytes()
if not value or value[0] != _SENTINEL:
# Empty, or legacy UTF-8 text (no sentinel — cannot start with NUL).
return value.decode("utf-8")
codec, payload = value[1], value[2:]
if codec == _CODEC_ZSTD:
return zstandard.ZstdDecompressor().decompress(payload).decode("utf-8")
return payload.decode("utf-8")
class CompressedText(TypeDecorator):
"""A ``str`` column stored as a zstd-compressed ``BLOB`` / ``BYTEA``.
Transparent at the ORM boundary: callers read and write ``str`` exactly as
they would with :class:`~sqlalchemy.Text`, and compression happens on the
way in and out. Legacy rows written when the column was ``TEXT`` decode
unchanged and re-frame on their next write, so no backfill is required.
Use only for columns that are never filtered, ordered, or pattern-matched
in SQL the stored bytes are opaque to the database.
"""
impl = LargeBinary
cache_ok = True
def process_bind_param(self, value: str | None, _dialect: object) -> bytes | None:
"""Compress on the way into the database."""
return encode(value)
def process_result_value(
self, value: bytes | str | memoryview | None, _dialect: object
) -> str | None:
"""Decompress on the way out of the database."""
return decode(value)
+8 -2
View File
@@ -3,14 +3,20 @@
from __future__ import annotations
from omnigent.db.db_models import SqlAgent
from omnigent.db.enum_codecs import AGENT_KIND
from omnigent.entities import Agent
def sql_agent_to_entity(row: SqlAgent) -> Agent:
def sql_agent_to_entity(row: SqlAgent, session_id: str | None = None) -> Agent:
"""
Convert a :class:`SqlAgent` ORM row to an :class:`Agent` entity.
:param row: The SQLAlchemy ORM row to convert.
:param session_id: Owning conversation id when this agent is
session-scoped; ``None`` for template agents. Callers that know
the owning conversation id (e.g. the conversation store) pass it
directly; the agent store leaves it ``None`` for templates (where
``row.kind`` is the "template" code).
:returns: An :class:`Agent` dataclass instance.
"""
return Agent(
@@ -21,5 +27,5 @@ def sql_agent_to_entity(row: SqlAgent) -> Agent:
version=row.version,
description=row.description,
updated_at=row.updated_at,
session_id=row.session_id,
session_id=None if row.kind == AGENT_KIND["template"] else session_id,
)
+364 -89
View File
@@ -2,14 +2,21 @@
from __future__ import annotations
import contextlib
import hashlib
from collections.abc import Iterator
from contextvars import ContextVar
from typing import Any
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
Float,
ForeignKey,
Index,
Integer,
LargeBinary,
SmallInteger,
String,
Text,
UniqueConstraint,
@@ -17,13 +24,72 @@ from sqlalchemy import (
text,
true,
)
from sqlalchemy.dialects.mysql import BINARY as MySQLBinary
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from omnigent.db.compression import CompressedText
# 32-byte sha256 digest column. LargeBinary → BYTEA (Postgres) / BLOB (SQLite),
# but MySQL cannot index a BLOB without a key-prefix length, so use fixed-length
# BINARY(32) there — an exact fit for the digest and fully indexable.
_CKSUM32 = LargeBinary(32).with_variant(MySQLBinary(32), "mysql")
class Base(DeclarativeBase):
"""Shared declarative base for all omnigent tables."""
# Default workspace id stamped on every row and used as the leading
# member of every composite primary key. 0 is the single-workspace /
# unassigned sentinel: with no workspace bound to the request, all rows
# live in workspace 0.
DEFAULT_WORKSPACE_ID = 0
# Ambient per-request workspace id. Stores are process-wide singletons, so
# the active workspace can't ride on the store instance — it lives here.
# OSS leaves this at the default (single-workspace 0); a multi-tenant
# deployment (e.g. universe) sets it per request from the authenticated
# context (via ``workspace_scope`` in middleware). Reads and inserts
# resolve it through ``current_workspace_id()`` so the same store code
# scopes to the caller's workspace without threading the id through every
# signature — keeping this file byte-identical across deployments.
_current_workspace_id: ContextVar[int] = ContextVar(
"omnigent_workspace_id", default=DEFAULT_WORKSPACE_ID
)
def current_workspace_id() -> int:
"""Return the workspace id bound to the active request/context.
Defaults to :data:`DEFAULT_WORKSPACE_ID` (0) the single-workspace OSS
deployment. Multi-tenant deployments set it per request so every
primary-key lookup, filter, and insert scopes to that workspace.
"""
return _current_workspace_id.get()
@contextlib.contextmanager
def workspace_scope(workspace_id: int) -> Iterator[None]:
"""Bind *workspace_id* for the duration of the ``with`` block.
Used by multi-tenant request middleware (and tests) to scope all
store access to one workspace; resets to the prior value on exit so
nested / concurrent contexts don't leak.
"""
token = _current_workspace_id.set(workspace_id)
try:
yield
finally:
_current_workspace_id.reset(token)
AGENT_KIND_TEMPLATE = "template"
AGENT_KIND_SESSION = "session"
POLICY_SCOPE_DEFAULT = "default"
POLICY_SCOPE_SESSION = "session"
class SqlAgent(Base):
"""
SQLAlchemy model for the ``agents`` table.
@@ -40,40 +106,47 @@ class SqlAgent(Base):
``"ag_abc123/a1b2c3d4e5f6..."``.
:param version: Monotonic version counter. Starts at 1, incremented
on each update via ``PUT /api/agents/{id}``.
:param kind: ``"template"`` for server-wide registered agents;
``"session"`` for per-conversation copies.
:param description: Optional free-text description of the agent's
purpose. ``None`` when not provided.
:param updated_at: Unix epoch seconds of the last update, or
``None`` if the agent has never been updated.
:param session_id: Owning conversation/session id for a
session-scoped agent. ``None`` for template agents uploaded
through ``POST /api/agents``.
"""
__tablename__ = "agents"
# Tenant partition key: Databricks workspace id owning this row (0 = default). Part of the PK.
workspace_id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
server_default="0",
default=current_workspace_id,
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
created_at: Mapped[int] = mapped_column(Integer)
name: Mapped[str] = mapped_column(String(256))
bundle_location: Mapped[str] = mapped_column(String(512))
version: Mapped[int] = mapped_column(Integer, default=1)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# Enum stored as a stable int code (see omnigent.db.enum_codecs
# AGENT_KIND: template=1, session=2). The store converts to/from the
# string name at the row↔entity boundary.
kind: Mapped[int] = mapped_column(SmallInteger)
description: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
session_id: Mapped[str | None] = mapped_column(
String(64),
ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=True,
)
__table_args__ = (
Index("ix_agents_created_at", "created_at"),
Index(
"ix_agents_template_name",
"name",
unique=True,
sqlite_where=text("session_id IS NULL"),
postgresql_where=text("session_id IS NULL"),
),
Index("ix_agents_session_id", "session_id", unique=True),
CheckConstraint("kind IN (1, 2)", name="ck_agents_kind"),
Index("ix_agents_created_at", "workspace_id", "created_at", "id"),
# Template agents have unique names; session-scoped agents (kind=2)
# may reuse the same name. That "unique only within the template set"
# rule can't be a partial unique index (MySQL has none), so it is
# enforced in the store (SqlAlchemyAgentStore.create). This plain index
# backs the (workspace_id, name, kind) lookup that check and get_by_name
# do — kind is included so the seek skips same-named session copies
# straight to the template row.
Index("ix_agents_name", "workspace_id", "name", "kind", "id"),
)
@@ -95,6 +168,14 @@ class SqlFile(Base):
__tablename__ = "files"
# Tenant partition key: Databricks workspace id owning this row (0 = default). Part of the PK.
workspace_id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
server_default="0",
default=current_workspace_id,
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
created_at: Mapped[int] = mapped_column(Integer)
filename: Mapped[str] = mapped_column(String(512))
@@ -103,8 +184,14 @@ class SqlFile(Base):
session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
__table_args__ = (
Index("ix_files_created_at", "created_at"),
Index("ix_files_session_id_created_at", "session_id", "created_at", "id"),
Index("ix_files_created_at", "workspace_id", "created_at", "id"),
Index(
"ix_files_session_id_created_at",
"workspace_id",
"session_id",
"created_at",
"id",
),
)
@@ -135,6 +222,14 @@ class SqlUser(Base):
__tablename__ = "users"
# Tenant partition key: Databricks workspace id owning this row (0 = default). Part of the PK.
workspace_id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
server_default="0",
default=current_workspace_id,
)
id: Mapped[str] = mapped_column(String(128), primary_key=True)
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=false())
password_hash: Mapped[str | None] = mapped_column(String(256), nullable=True)
@@ -177,8 +272,19 @@ class SqlAccountToken(Base):
__tablename__ = "account_tokens"
# Tenant partition key: Databricks workspace id owning this row (0 = default). Part of the PK.
workspace_id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
server_default="0",
default=current_workspace_id,
)
id: Mapped[str] = mapped_column(String(128), primary_key=True)
kind: Mapped[str] = mapped_column(String(16), nullable=False)
# Enum stored as a stable int code (see omnigent.db.enum_codecs
# ACCOUNT_TOKEN_KIND: invite=1, magic=2). The store converts to/from
# the string name at the row↔entity boundary.
kind: Mapped[int] = mapped_column(SmallInteger, nullable=False)
user_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[int] = mapped_column(Integer, nullable=False)
@@ -187,8 +293,8 @@ class SqlAccountToken(Base):
invited_is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=false())
__table_args__ = (
CheckConstraint("kind IN ('invite', 'magic')", name="ck_account_tokens_kind"),
Index("ix_account_tokens_expires_at", "expires_at"),
CheckConstraint("kind IN (1, 2)", name="ck_account_tokens_kind"),
Index("ix_account_tokens_expires_at", "workspace_id", "expires_at", "id"),
)
@@ -215,21 +321,34 @@ class SqlSessionPermission(Base):
__tablename__ = "session_permissions"
# Tenant partition key: Databricks workspace id owning this row (0 = default). Part of the PK.
workspace_id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
server_default="0",
default=current_workspace_id,
)
user_id: Mapped[str] = mapped_column(
String(128),
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
conversation_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("conversations.id", ondelete="CASCADE"),
primary_key=True,
)
level: Mapped[int] = mapped_column(Integer, nullable=False)
__table_args__ = (
CheckConstraint("level IN (1, 2, 3, 4)", name="ck_session_permissions_level"),
Index("ix_session_permissions_conversation_id", "conversation_id"),
# Lookups by conversation (get_session_owner) filter workspace_id +
# conversation_id; user_id trails to complete the PK.
Index(
"ix_session_permissions_conversation_id",
"workspace_id",
"conversation_id",
"user_id",
),
)
@@ -246,8 +365,7 @@ class SqlConversation(Base):
created.
:param updated_at: Unix epoch seconds when the conversation was
last updated (item append, title change, etc.).
:param title: Optional human-readable title for the conversation.
``None`` when not provided.
:param title: Human-readable title; empty string when untitled.
:param kind: Conversation type. ``"default"`` for user-initiated,
``"sub_agent"`` for sub-agent execution conversations.
:param parent_conversation_id: For Phase 4 named sub-agents,
@@ -312,36 +430,41 @@ class SqlConversation(Base):
__tablename__ = "conversations"
# Tenant partition key: Databricks workspace id owning this row (0 = default). Part of the PK.
workspace_id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
server_default="0",
default=current_workspace_id,
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
created_at: Mapped[int] = mapped_column(Integer)
updated_at: Mapped[int] = mapped_column(Integer)
title: Mapped[str | None] = mapped_column(Text, nullable=True)
kind: Mapped[str] = mapped_column(String(32), default="default")
title: Mapped[str] = mapped_column(String(768), nullable=False, server_default="")
# Enum stored as a stable int code (see omnigent.db.enum_codecs
# CONVERSATION_KIND: default=1, sub_agent=2). The store converts to/from
# the string name at the row↔entity boundary.
kind: Mapped[int] = mapped_column(SmallInteger, default=1)
parent_conversation_id: Mapped[str | None] = mapped_column(
String(64),
ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=True,
)
root_conversation_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False,
)
agent_id: Mapped[str | None] = mapped_column(
String(64),
ForeignKey("agents.id", ondelete="CASCADE"),
nullable=True,
)
runner_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Host that launched (or should launch) the runner for this
# session. Set when a session is created via the Web UI on a
# specific host. FK to hosts.host_id (a unique column); ON DELETE
# SET NULL so removing a host clears the binding rather than
# orphaning it — and host_id -> NULL keeps the
# workspace-required CHECK below satisfied.
# specific host. No FK: host records are managed outside this
# table; deletion is handled explicitly by the application.
host_id: Mapped[str | None] = mapped_column(
String(64),
ForeignKey("hosts.host_id", ondelete="SET NULL"),
nullable=True,
)
# Per-session reasoning-effort hint, e.g. "high". Nullable;
@@ -374,13 +497,13 @@ class SqlConversation(Base):
# NULL when no policy has written state yet; empty JSON object
# "{}" is equivalent. Stored as Text (not a native JSON column)
# for SQLite compatibility.
session_state: Mapped[str | None] = mapped_column(Text, nullable=True)
session_state: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
# JSON-serialized cumulative LLM token usage for policy
# callables. Shape: {"input_tokens": N, "output_tokens": M,
# "total_tokens": T, "cache_read_input_tokens": C1,
# "cache_creation_input_tokens": C2, "total_cost_usd": X}.
# NULL when no LLM calls have been recorded yet.
session_usage: Mapped[str | None] = mapped_column(Text, nullable=True)
session_usage: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
# Pass-through CLI args for a native terminal wrapper (claude /
# codex), JSON-encoded list of strings, e.g.
# '["--dangerously-skip-permissions"]'. NULL for non-native
@@ -390,7 +513,7 @@ class SqlConversation(Base):
# here. A flat list (not a dict) is deliberate: there is no key for
# a user to smuggle internal wiring through. See
# designs/NATIVE_RUNNER_SERVER_LAUNCH.md.
terminal_launch_args: Mapped[str | None] = mapped_column(Text, nullable=True)
terminal_launch_args: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
# Absolute path on the host where the runner cd's. Required
# when host_id is set; CHECK constraint below. When a git worktree
# was created for the session, this is the worktree directory path.
@@ -410,41 +533,49 @@ class SqlConversation(Base):
)
__table_args__ = (
CheckConstraint("kind IN ('default', 'sub_agent')", name="ck_conversations_kind"),
CheckConstraint("kind IN (1, 2)", name="ck_conversations_kind"),
CheckConstraint(
"host_id IS NULL OR workspace IS NOT NULL",
name="ck_conversations_workspace_required_for_host",
),
Index("ix_conversations_created_at", "created_at"),
Index("ix_conversations_updated_at", "updated_at"),
Index("ix_conversations_kind", "kind"),
# Reconnect reconciliation queries conversations by host_id on
# every host reconnect; index it to avoid a full scan.
Index("ix_conversations_host_id", "host_id"),
Index("ix_conversations_root_conversation_id", "root_conversation_id"),
# Phase 4: partial unique index on (parent_conversation_id,
# title) prevents two same-named children under the same
# parent (G36 race protection at the DB layer). The
# ``sqlite_where`` / ``postgresql_where`` clauses scope the
# index so multiple top-level conversations (NULL parent)
# remain valid.
Index("ix_conversations_created_at", "workspace_id", "created_at", "id"),
Index("ix_conversations_updated_at", "workspace_id", "updated_at", "id"),
Index("ix_conversations_kind", "workspace_id", "kind", "id"),
# Agent lookups: find the conversation(s) that own a given agent.
Index("ix_conversations_agent_id", "workspace_id", "agent_id", "id"),
Index(
"ix_conversations_root_conversation_id",
"workspace_id",
"root_conversation_id",
"id",
),
# Reconnect/relaunch reconciliation looks up a runner's session(s)
# by runner_id (list_conversations_by_runner_id) on every runner
# reconnect; index it to avoid a full scan.
Index("ix_conversations_runner_id", "workspace_id", "runner_id", "id"),
# Unique index on (parent_conversation_id, title) prevents two
# same-named children under the same parent (G36 race protection at
# the DB layer). Top-level conversations (NULL parent) are exempt
# automatically: NULLs are distinct in a unique index, so no WHERE
# predicate is needed — keeping it a plain index MySQL can build.
Index(
"ix_conversations_parent_title_unique",
"workspace_id",
"parent_conversation_id",
"title",
unique=True,
sqlite_where=text("parent_conversation_id IS NOT NULL"),
postgresql_where=text("parent_conversation_id IS NOT NULL"),
mysql_length={"title": 512},
),
# Partial composite index for child-session listing
# Composite index for child-session listing
# (list_conversations(kind="sub_agent", parent_conversation_id=...)).
# Non-unique, so no scoping predicate is required; it simply indexes
# every parented row rather than only the sub-agent ones.
Index(
"idx_conversations_parent",
"workspace_id",
"parent_conversation_id",
text("created_at DESC"),
text("id DESC"),
sqlite_where=text("kind = 'sub_agent'"),
postgresql_where=text("kind = 'sub_agent'"),
),
)
@@ -480,15 +611,32 @@ class SqlConversationItem(Base):
__tablename__ = "conversation_items"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
conversation_id: Mapped[str] = mapped_column(
String(64), ForeignKey("conversations.id", ondelete="CASCADE")
# Tenant partition key: Databricks workspace id owning this row (0 = default). Part of the PK.
workspace_id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
server_default="0",
default=current_workspace_id,
)
# conversation_id leads id in the PK so a conversation's items stay
# contiguous for the per-conversation prefix scans that dominate reads.
conversation_id: Mapped[str] = mapped_column(
String(64),
primary_key=True,
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
response_id: Mapped[str] = mapped_column(String(64))
created_at: Mapped[int] = mapped_column(Integer)
status: Mapped[str] = mapped_column(String(32), default="completed")
# Enum stored as a stable int code (see omnigent.db.enum_codecs
# ITEM_STATUS: completed=1). Only "completed" is written today, but the
# CHECK admits the wider OpenAI-style status vocabulary reserved there.
status: Mapped[int] = mapped_column(SmallInteger, default=1)
position: Mapped[int] = mapped_column(Integer)
type: Mapped[str] = mapped_column(String(32))
# Enum stored as a stable int code (see omnigent.db.enum_codecs
# ITEM_TYPE). The store converts to/from the string name at the
# row↔entity boundary.
type: Mapped[int] = mapped_column(SmallInteger)
data: Mapped[str] = mapped_column(Text)
search_text: Mapped[str] = mapped_column(Text)
created_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
@@ -496,11 +644,25 @@ class SqlConversationItem(Base):
__table_args__ = (
Index(
"ix_conversation_items_conversation_id_position",
"workspace_id",
"conversation_id",
"position",
unique=True,
),
Index("ix_conversation_items_response_id", "response_id"),
# Fork-truncation looks up by workspace_id + conversation_id +
# response_id; id trails to complete the PK.
Index(
"ix_conversation_items_response_id",
"workspace_id",
"conversation_id",
"response_id",
"id",
),
CheckConstraint(
"type IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)",
name="ck_conversation_items_type",
),
CheckConstraint("status IN (1, 2, 3, 4)", name="ck_conversation_items_status"),
)
@@ -542,9 +704,16 @@ class SqlConversationLabel(Base):
__tablename__ = "conversation_labels"
# Tenant partition key: Databricks workspace id owning this row (0 = default). Part of the PK.
workspace_id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
server_default="0",
default=current_workspace_id,
)
conversation_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("conversations.id", ondelete="CASCADE"),
primary_key=True,
)
key: Mapped[str] = mapped_column(String(128), primary_key=True)
@@ -590,24 +759,64 @@ class SqlComment(Base):
__tablename__ = "comments"
# Tenant partition key: Databricks workspace id owning this row (0 = default). Part of the PK.
workspace_id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
server_default="0",
default=current_workspace_id,
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
conversation_id: Mapped[str] = mapped_column(String(64))
path: Mapped[str] = mapped_column(String(4096))
start_index: Mapped[int] = mapped_column(Integer)
end_index: Mapped[int] = mapped_column(Integer)
body: Mapped[str] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(32))
body: Mapped[str] = mapped_column(CompressedText)
# Enum stored as a stable int code (see omnigent.db.enum_codecs
# COMMENT_STATUS: draft=1, addressed=2).
status: Mapped[int] = mapped_column(SmallInteger)
created_at: Mapped[int] = mapped_column(Integer)
updated_at: Mapped[int] = mapped_column(BigInteger)
anchor_content: Mapped[str | None] = mapped_column(Text, nullable=True)
anchor_content: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
created_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
__table_args__ = (
Index("ix_comments_conversation_id", "conversation_id"),
Index("ix_comments_created_at", "created_at"),
CheckConstraint("status IN (1, 2)", name="ck_comments_status"),
# Serves list_for_conversation: WHERE workspace_id + conversation_id
# ORDER BY created_at, id. Folds created_at in (over a bare
# conversation_id index) so the sort is index-ordered; trails id to
# complete the PK.
Index(
"ix_comments_conversation_id",
"workspace_id",
"conversation_id",
"created_at",
"id",
),
)
def policy_name_cksum(name: str) -> bytes:
"""Return the sha256 digest of a policy name.
This 32-byte digest is what the name-uniqueness indexes key on instead
of the raw ``VARCHAR(256)`` name a fixed, compact index entry. Two
names collide iff their digests do, so uniqueness is preserved.
"""
return hashlib.sha256(name.encode("utf-8")).digest()
def _default_policy_name_cksum(context: Any) -> bytes:
"""Column default: derive ``name_cksum`` from the bound ``name`` on INSERT.
Mirrors the ``workspace_id`` default pattern so every ORM insert stamps
the checksum without the caller setting it. Column defaults do not fire
on UPDATE, so renames recompute it explicitly in the store.
"""
return policy_name_cksum(context.get_current_parameters()["name"])
class SqlPolicy(Base):
"""
SQLAlchemy model for the ``policies`` table.
@@ -621,9 +830,14 @@ class SqlPolicy(Base):
are created via ``POST /v1/policies``.
:param id: Opaque PK, e.g. ``"pol_a1b2c3..."``.
:param name: Human-readable name. UNIQUE per
``(session_id, name)`` for session policies; globally
unique for default policies (``session_id IS NULL``).
:param name: Human-readable name. UNIQUE per session for
session policies; globally unique for default policies
(``session_id IS NULL``). Uniqueness is enforced on
``name_cksum`` rather than this column.
:param name_cksum: sha256 digest of ``name`` (32 bytes). The
name-uniqueness indexes key on this compact digest instead
of the wide ``VARCHAR(256)`` name. Stamped on INSERT by a
column default; recomputed by the store on rename.
:param session_id: FK to ``conversations.id``. ``None`` for
server-wide default policies. ``ON DELETE CASCADE`` so
removing a session cleans up its policies.
@@ -639,6 +853,10 @@ class SqlPolicy(Base):
the handler is a direct callable or for ``type="url"``.
:param enabled: Whether the engine consults this row.
Defaults to true.
:param scope: ``"default"`` for server-wide policies;
``"session"`` for session-scoped policies. Explicit
discriminator so queries filter by column value instead
of checking ``session_id IS NULL``.
:param created_by: User ID of the admin who created this
policy. ``None`` in single-user mode or for
session-scoped policies.
@@ -646,17 +864,30 @@ class SqlPolicy(Base):
__tablename__ = "policies"
# Tenant partition key: Databricks workspace id owning this row (0 = default). Part of the PK.
workspace_id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
server_default="0",
default=current_workspace_id,
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(256))
# sha256(name) — the value the name-uniqueness indexes key on instead of
# the wide name column. Stamped from `name` on INSERT via the column
# default; the store recomputes it on rename (defaults don't fire on UPDATE).
name_cksum: Mapped[bytes] = mapped_column(_CKSUM32, default=_default_policy_name_cksum)
# Nullable: NULL for server-wide default policies.
session_id: Mapped[str | None] = mapped_column(
String(64),
ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=True,
)
created_at: Mapped[int] = mapped_column(Integer)
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
type: Mapped[str] = mapped_column(String(16))
# Handler discriminator stored as a stable int code (see
# omnigent.db.enum_codecs POLICY_TYPE: python=1, url=2).
type: Mapped[int] = mapped_column(SmallInteger)
# Dotted import path (type="python") or HTTPS URL
# (type="url") for the policy handler.
handler: Mapped[str] = mapped_column(Text)
@@ -666,12 +897,32 @@ class SqlPolicy(Base):
# FunctionRef.arguments pattern.
factory_params: Mapped[str | None] = mapped_column(Text, nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, server_default=true())
# "default" for server-wide policies; "session" for per-conversation
# copies. Mirrors the agents.kind pattern so queries filter by column
# value rather than session_id IS NULL. Enum stored as a stable int
# code (see omnigent.db.enum_codecs POLICY_SCOPE: default=1, session=2).
scope: Mapped[int] = mapped_column(SmallInteger)
created_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
__table_args__ = (
Index("ix_policies_created_at", "created_at"),
Index("ix_policies_session_id", "session_id"),
UniqueConstraint("session_id", "name", name="uq_policies_session_id_name"),
CheckConstraint("type IN (1, 2)", name="ck_policies_type"),
CheckConstraint("scope IN (1, 2)", name="ck_policies_scope"),
Index("ix_policies_created_at", "workspace_id", "created_at", "id"),
Index("ix_policies_session_id", "workspace_id", "session_id", "id"),
# Name uniqueness keys on name_cksum (sha256 of name) rather than the
# wide name column, for a compact 32-byte index entry.
UniqueConstraint(
"workspace_id",
"session_id",
"name_cksum",
name="uq_policies_session_id_name_cksum",
),
# Default policies must have unique names; session-scoped policies
# may reuse the same name. That "unique only within the default set"
# rule can't be a partial unique index (MySQL has none), so it is
# enforced in the store (add_default / update_default). This plain
# index just backs the name_cksum lookup those checks perform.
Index("ix_policies_name_cksum", "workspace_id", "name_cksum", "id"),
)
@@ -686,7 +937,8 @@ class SqlHost(Base):
:param host_id: Stable host identifier from the host's local
``~/.omnigent/config.yaml``, e.g. ``"host_a1b2c3d4e5f6..."``.
:param name: Human-readable name from ``config.yaml``, e.g.
``"corey-laptop"``. Displayed in the Web UI host picker.
``"corey-laptop"``. Displayed in the Web UI host picker. Max 64
characters.
:param owner: User ID from the Databricks auth Bearer token
presented during the host's WebSocket handshake, e.g.
``"corey.zumar@databricks.com"``.
@@ -727,10 +979,20 @@ class SqlHost(Base):
__tablename__ = "hosts"
owner: Mapped[str] = mapped_column(String(256), primary_key=True)
name: Mapped[str] = mapped_column(String(256), primary_key=True)
host_id: Mapped[str] = mapped_column(String(64))
status: Mapped[str] = mapped_column(String(16))
# Tenant partition key: Databricks workspace id owning this row (0 = default). Part of the PK.
workspace_id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
server_default="0",
default=current_workspace_id,
)
host_id: Mapped[str] = mapped_column(String(64), primary_key=True)
owner: Mapped[str] = mapped_column(String(256), nullable=False)
name: Mapped[str] = mapped_column(String(64), nullable=False)
# Enum stored as a stable int code (see omnigent.db.enum_codecs
# HOST_STATUS: online=1, offline=2).
status: Mapped[int] = mapped_column(SmallInteger)
created_at: Mapped[int] = mapped_column(Integer)
updated_at: Mapped[int] = mapped_column(Integer)
token_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
@@ -741,11 +1003,16 @@ class SqlHost(Base):
__table_args__ = (
CheckConstraint(
"status IN ('online', 'offline')",
"status IN (1, 2)",
name="ck_hosts_status",
),
UniqueConstraint("host_id", name="uq_hosts_host_id"),
UniqueConstraint("token_hash", name="uq_hosts_token_hash"),
# (workspace_id, owner, name) was the old PK; keep it unique so the
# upsert-on-connect logic (look up by owner+name to detect host_id
# rotation) stays consistent.
UniqueConstraint("workspace_id", "owner", "name", name="uq_hosts_workspace_owner_name"),
# resolve_launch_token filters workspace_id + token_hash, so scoping
# the unique to the workspace keeps that lookup index-served.
UniqueConstraint("workspace_id", "token_hash", name="uq_hosts_token_hash"),
)
@@ -788,6 +1055,14 @@ class SqlUserDailyCost(Base):
__tablename__ = "user_daily_cost"
# Tenant partition key: Databricks workspace id owning this row (0 = default). Part of the PK.
workspace_id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
server_default="0",
default=current_workspace_id,
)
user_id: Mapped[str] = mapped_column(String(128), primary_key=True)
day_utc: Mapped[str] = mapped_column(String(10), primary_key=True)
cost_usd: Mapped[float] = mapped_column(Float, nullable=False)
+249
View File
@@ -0,0 +1,249 @@
"""Name↔int codecs for enum-like columns stored as ``SMALLINT``.
Several low-cardinality closed-set columns (``conversations.kind``,
``conversation_items.type``/``status``, ``comments.status``,
``account_tokens.kind``, ``policies.type``, ``policies.scope``,
``hosts.status``, ``agents.kind``) are stored as integer codes rather
than their string names smaller rows and a tighter ``CHECK`` than a
free ``VARCHAR``. The string names remain the
contract for entities, the HTTP API, the web client, and the SDKs; the
integer form never leaves the store rowentity boundary. These codecs are
the single place that translates between the two.
Codes are STABLE and append-only: never renumber or reuse a shipped code,
and leave gaps rather than reordering, so old rows keep their meaning.
This mirrors :data:`omnigent.server.auth.LEVEL_READ` and friends, the
existing int-coded ``session_permissions.level``.
"""
from __future__ import annotations
from omnigent.entities.conversation import ITEM_TYPE_TO_DATA_CLS
# ── Code tables (name → stable int code) ───────────────
CONVERSATION_KIND: dict[str, int] = {
"default": 1,
"sub_agent": 2,
}
# Item type codes. The key set is kept in lock-step with
# ITEM_TYPE_TO_DATA_CLS (the app-layer source of truth) by
# _assert_item_type_codes_cover_data_classes below, so a newly added item
# type cannot ship without a code. Codes are append-only.
ITEM_TYPE: dict[str, int] = {
"message": 1,
"function_call": 2,
"function_call_output": 3,
"reasoning": 4,
"error": 5,
"compaction": 6,
"native_tool": 7,
"resource_event": 8,
"routing_decision": 9,
"slash_command": 10,
"terminal_command": 11,
}
# Item status codes. Only "completed" is written today (items are final on
# append), but the field is semantically an OpenAI-style status that may
# widen, so codes for the rest of that vocabulary are reserved up front and
# the column CHECK admits all of them.
ITEM_STATUS: dict[str, int] = {
"completed": 1,
"in_progress": 2,
"incomplete": 3,
"failed": 4,
}
COMMENT_STATUS: dict[str, int] = {
"draft": 1,
"addressed": 2,
}
ACCOUNT_TOKEN_KIND: dict[str, int] = {
"invite": 1,
"magic": 2,
}
POLICY_TYPE: dict[str, int] = {
"python": 1,
"url": 2,
}
HOST_STATUS: dict[str, int] = {
"online": 1,
"offline": 2,
}
AGENT_KIND: dict[str, int] = {
"template": 1,
"session": 2,
}
POLICY_SCOPE: dict[str, int] = {
"default": 1,
"session": 2,
}
def _assert_item_type_codes_cover_data_classes() -> None:
"""
Guard that :data:`ITEM_TYPE` matches the app's item-type registry.
Raised at import time (and asserted by a unit test) so a new item type
added to ``ITEM_TYPE_TO_DATA_CLS`` without a corresponding code fails
loudly instead of silently breaking persistence.
:raises RuntimeError: If the two key sets diverge.
"""
missing = set(ITEM_TYPE_TO_DATA_CLS) - set(ITEM_TYPE)
extra = set(ITEM_TYPE) - set(ITEM_TYPE_TO_DATA_CLS)
if missing or extra:
raise RuntimeError(
"ITEM_TYPE codes are out of sync with ITEM_TYPE_TO_DATA_CLS "
f"(missing codes for {sorted(missing)}, "
f"unknown types {sorted(extra)})."
)
_assert_item_type_codes_cover_data_classes()
# ── Encode / decode ────────────────────────────────────
def _invert(table: dict[str, int]) -> dict[int, str]:
"""Return the code→name inverse of a name→code table."""
return {code: name for name, code in table.items()}
_CODE_TO_NAME: dict[int, dict[int, str]] = {}
def _encode(table: dict[str, int], name: str, *, field: str) -> int:
"""
Map an enum *name* to its stable integer code.
:param table: The namecode table for the field.
:param name: The string enum name, e.g. ``"sub_agent"``.
:param field: Field label used in the error message, e.g.
``"conversations.kind"``.
:returns: The integer code.
:raises ValueError: If *name* is not a known value for the field.
"""
try:
return table[name]
except KeyError:
raise ValueError(f"unknown {field} value: {name!r}") from None
def _decode(table: dict[str, int], code: int, *, field: str) -> str:
"""
Map an integer *code* back to its enum name.
:param table: The namecode table for the field.
:param code: The stored integer code.
:param field: Field label used in the error message, e.g.
``"conversations.kind"``.
:returns: The string enum name.
:raises ValueError: If *code* is not a known code for the field.
"""
inverse = _CODE_TO_NAME.get(id(table))
if inverse is None:
inverse = _invert(table)
_CODE_TO_NAME[id(table)] = inverse
try:
return inverse[code]
except KeyError:
raise ValueError(f"unknown {field} code: {code!r}") from None
def encode_conversation_kind(name: str) -> int:
"""Encode a ``conversations.kind`` name to its int code."""
return _encode(CONVERSATION_KIND, name, field="conversations.kind")
def decode_conversation_kind(code: int) -> str:
"""Decode a ``conversations.kind`` int code to its name."""
return _decode(CONVERSATION_KIND, code, field="conversations.kind")
def encode_item_type(name: str) -> int:
"""Encode a ``conversation_items.type`` name to its int code."""
return _encode(ITEM_TYPE, name, field="conversation_items.type")
def decode_item_type(code: int) -> str:
"""Decode a ``conversation_items.type`` int code to its name."""
return _decode(ITEM_TYPE, code, field="conversation_items.type")
def encode_item_status(name: str) -> int:
"""Encode a ``conversation_items.status`` name to its int code."""
return _encode(ITEM_STATUS, name, field="conversation_items.status")
def decode_item_status(code: int) -> str:
"""Decode a ``conversation_items.status`` int code to its name."""
return _decode(ITEM_STATUS, code, field="conversation_items.status")
def encode_comment_status(name: str) -> int:
"""Encode a ``comments.status`` name to its int code."""
return _encode(COMMENT_STATUS, name, field="comments.status")
def decode_comment_status(code: int) -> str:
"""Decode a ``comments.status`` int code to its name."""
return _decode(COMMENT_STATUS, code, field="comments.status")
def encode_account_token_kind(name: str) -> int:
"""Encode an ``account_tokens.kind`` name to its int code."""
return _encode(ACCOUNT_TOKEN_KIND, name, field="account_tokens.kind")
def decode_account_token_kind(code: int) -> str:
"""Decode an ``account_tokens.kind`` int code to its name."""
return _decode(ACCOUNT_TOKEN_KIND, code, field="account_tokens.kind")
def encode_policy_type(name: str) -> int:
"""Encode a ``policies.type`` name to its int code."""
return _encode(POLICY_TYPE, name, field="policies.type")
def decode_policy_type(code: int) -> str:
"""Decode a ``policies.type`` int code to its name."""
return _decode(POLICY_TYPE, code, field="policies.type")
def encode_host_status(name: str) -> int:
"""Encode a ``hosts.status`` name to its int code."""
return _encode(HOST_STATUS, name, field="hosts.status")
def decode_host_status(code: int) -> str:
"""Decode a ``hosts.status`` int code to its name."""
return _decode(HOST_STATUS, code, field="hosts.status")
def encode_agent_kind(name: str) -> int:
"""Encode an ``agents.kind`` name to its int code."""
return _encode(AGENT_KIND, name, field="agents.kind")
def decode_agent_kind(code: int) -> str:
"""Decode an ``agents.kind`` int code to its name."""
return _decode(AGENT_KIND, code, field="agents.kind")
def encode_policy_scope(name: str) -> int:
"""Encode a ``policies.scope`` name to its int code."""
return _encode(POLICY_SCOPE, name, field="policies.scope")
def decode_policy_scope(code: int) -> str:
"""Decode a ``policies.scope`` int code to its name."""
return _decode(POLICY_SCOPE, code, field="policies.scope")
@@ -76,6 +76,7 @@ def upgrade() -> None:
unique=True,
sqlite_where=sa.text("parent_conversation_id IS NOT NULL"),
postgresql_where=sa.text("parent_conversation_id IS NOT NULL"),
mysql_length={"title": 512},
)
op.create_table(
"files",
@@ -231,6 +232,16 @@ def upgrade() -> None:
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("conversation_labels")
# MySQL requires FK constraints to be dropped before the indexes that back them.
if op.get_bind().dialect.name == "mysql":
with op.batch_alter_table("pending_tool_calls") as batch_op:
for fk in sa.inspect(op.get_bind()).get_foreign_keys("pending_tool_calls"):
if fk["name"]:
batch_op.drop_constraint(fk["name"], type_="foreignkey")
with op.batch_alter_table("tasks") as batch_op:
for fk in sa.inspect(op.get_bind()).get_foreign_keys("tasks"):
if fk["name"]:
batch_op.drop_constraint(fk["name"], type_="foreignkey")
op.drop_index("ix_pending_tool_calls_task_id", table_name="pending_tool_calls")
op.drop_index("ix_pending_tool_calls_root_task_id", table_name="pending_tool_calls")
op.drop_table("pending_tool_calls")
@@ -51,5 +51,9 @@ def upgrade() -> None:
def downgrade() -> None:
# ── conversations ─────────────────────────────────────
with op.batch_alter_table("conversations") as batch_op:
batch_op.drop_constraint("fk_conversations_agent_id", type_="foreignkey")
if any(
f["name"] == "fk_conversations_agent_id"
for f in sa.inspect(op.get_bind()).get_foreign_keys("conversations")
):
batch_op.drop_constraint("fk_conversations_agent_id", type_="foreignkey")
batch_op.drop_column("agent_id")
@@ -21,17 +21,26 @@ down_revision: str | None = "b2c3d4e5f6a7"
def upgrade() -> None:
"""Restructure policies table for session-scoped handler policies."""
# MySQL requires dropping FK constraints before the indexes/unique constraints
# that back them. Drop any FK on agent_id before dropping the unique constraint.
if op.get_bind().dialect.name == "mysql":
with op.batch_alter_table("policies") as batch_op:
for fk in sa.inspect(op.get_bind()).get_foreign_keys("policies"):
if fk["name"] and "agent_id" in fk["constrained_columns"]:
batch_op.drop_constraint(fk["name"], type_="foreignkey")
with op.batch_alter_table("policies") as batch_op:
batch_op.add_column(sa.Column("session_id", sa.String(64), nullable=True))
batch_op.add_column(sa.Column("handler", sa.Text(), nullable=True))
batch_op.add_column(sa.Column("factory_params", sa.Text(), nullable=True))
batch_op.create_foreign_key(
"fk_policies_session_id",
"conversations",
["session_id"],
["id"],
ondelete="CASCADE",
)
# MySQL: skip FK creation (FKs removed in p1a2b3c4d5e6 anyway).
if op.get_bind().dialect.name != "mysql":
batch_op.create_foreign_key(
"fk_policies_session_id",
"conversations",
["session_id"],
["id"],
ondelete="CASCADE",
)
batch_op.create_index("ix_policies_session_id", ["session_id"])
batch_op.create_unique_constraint("uq_policies_session_id_name", ["session_id", "name"])
batch_op.drop_index("ix_policies_agent_id")
@@ -44,16 +53,42 @@ def upgrade() -> None:
def downgrade() -> None:
"""Restore agent-scoped columns and remove session-scoped ones."""
# MySQL doesn't allow DEFAULT on TEXT columns; add as nullable then
# tighten nullable after — the table is being downgraded so no live rows exist.
mysql = op.get_bind().dialect.name == "mysql"
with op.batch_alter_table("policies") as batch_op:
batch_op.add_column(sa.Column("prompt", sa.Text(), nullable=False, server_default=""))
batch_op.add_column(sa.Column("phases", sa.Text(), nullable=False, server_default="[]"))
batch_op.add_column(sa.Column("actions", sa.Text(), nullable=False, server_default="[]"))
batch_op.add_column(
sa.Column(
"prompt",
sa.Text(),
nullable=mysql,
server_default=None if mysql else "",
)
)
batch_op.add_column(
sa.Column(
"phases",
sa.Text(),
nullable=mysql,
server_default=None if mysql else "[]",
)
)
batch_op.add_column(
sa.Column(
"actions",
sa.Text(),
nullable=mysql,
server_default=None if mysql else "[]",
)
)
batch_op.add_column(sa.Column("agent_id", sa.String(64), nullable=True))
batch_op.create_unique_constraint("uq_policies_agent_id_name", ["agent_id", "name"])
batch_op.create_index("ix_policies_agent_id", ["agent_id"])
batch_op.drop_constraint("uq_policies_session_id_name", type_="unique")
batch_op.drop_index("ix_policies_session_id")
batch_op.drop_constraint("fk_policies_session_id", type_="foreignkey")
# MySQL: FK was never added in upgrade (skipped for MySQL compatibility).
if not mysql:
batch_op.drop_constraint("fk_policies_session_id", type_="foreignkey")
batch_op.drop_column("factory_params")
batch_op.drop_column("handler")
batch_op.drop_column("session_id")
@@ -55,22 +55,30 @@ def upgrade() -> None:
# SET NULL clears the binding when a host is removed, which keeps
# the workspace-required check satisfied (host_id -> NULL).
batch_op.create_index("ix_conversations_host_id", ["host_id"])
batch_op.create_foreign_key(
"fk_conversations_host_id_hosts",
"hosts",
["host_id"],
["host_id"],
ondelete="SET NULL",
)
# MySQL 8.0.16+ forbids a column from appearing in both a CHECK
# constraint and a FK referential action. Skip FK creation on MySQL
# since migration p1a2b3c4d5e6 removes all FKs anyway.
if op.get_bind().dialect.name != "mysql":
batch_op.create_foreign_key(
"fk_conversations_host_id_hosts",
"hosts",
["host_id"],
["host_id"],
ondelete="SET NULL",
)
def downgrade() -> None:
"""Drop the host_id FK + index, then the workspace column and check."""
mysql = op.get_bind().dialect.name == "mysql"
with op.batch_alter_table("conversations") as batch_op:
batch_op.drop_constraint(
"fk_conversations_host_id_hosts",
type_="foreignkey",
)
# FK was never created on MySQL (skipped in upgrade due to MySQL 8.0.16+
# restriction on columns used in both CHECK and FK referential actions).
if not mysql:
batch_op.drop_constraint(
"fk_conversations_host_id_hosts",
type_="foreignkey",
)
batch_op.drop_index("ix_conversations_host_id")
batch_op.drop_constraint(
"ck_conversations_workspace_required_for_host",
@@ -32,6 +32,12 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Drop the tasks table and all of its indexes."""
# MySQL requires dropping FK constraints before the indexes that back them.
if op.get_bind().dialect.name == "mysql":
with op.batch_alter_table("tasks") as batch_op:
for fk in sa.inspect(op.get_bind()).get_foreign_keys("tasks"):
if fk["name"]:
batch_op.drop_constraint(fk["name"], type_="foreignkey")
with op.batch_alter_table("tasks") as batch_op:
batch_op.drop_index("ix_tasks_conversation_id")
batch_op.drop_index("ix_tasks_agent_id")
@@ -111,7 +111,8 @@ def downgrade() -> None:
op.execute(sa.text("DELETE FROM agents WHERE session_id IS NOT NULL"))
with op.batch_alter_table("agents") as batch_op:
batch_op.drop_index("ix_agents_template_name")
batch_op.drop_index("ix_agents_session_id")
# MySQL requires the FK to be dropped before the index that backs it.
batch_op.drop_constraint("fk_agents_session_id", type_="foreignkey")
batch_op.drop_index("ix_agents_session_id")
batch_op.drop_column("session_id")
batch_op.create_unique_constraint("uq_agents_name", ["name"])
@@ -63,27 +63,44 @@ def upgrade() -> None:
# root_id. Each iteration covers one additional level of the
# spawn tree; loops until the UPDATE affects zero rows. Bounded
# by the maximum tree depth, which is small in practice.
#
# MySQL does not allow referencing the same table in a subquery
# inside an UPDATE statement (error 1093). Use a JOIN-based UPDATE
# for MySQL and the standard subquery form for SQLite/PostgreSQL.
bind = op.get_bind()
for _ in range(64):
result = bind.execute(
sa.text(
"""
UPDATE conversations
SET root_conversation_id = (
SELECT parent.root_conversation_id
FROM conversations AS parent
WHERE parent.id = conversations.parent_conversation_id
)
WHERE root_conversation_id IS NULL
AND parent_conversation_id IS NOT NULL
AND (
SELECT parent.root_conversation_id
FROM conversations AS parent
WHERE parent.id = conversations.parent_conversation_id
) IS NOT NULL
"""
)
is_mysql = bind.dialect.name == "mysql"
if is_mysql:
backfill_sql = sa.text(
"""
UPDATE conversations
JOIN conversations AS parent
ON parent.id = conversations.parent_conversation_id
SET conversations.root_conversation_id = parent.root_conversation_id
WHERE conversations.root_conversation_id IS NULL
AND conversations.parent_conversation_id IS NOT NULL
AND parent.root_conversation_id IS NOT NULL
"""
)
else:
backfill_sql = sa.text(
"""
UPDATE conversations
SET root_conversation_id = (
SELECT parent.root_conversation_id
FROM conversations AS parent
WHERE parent.id = conversations.parent_conversation_id
)
WHERE root_conversation_id IS NULL
AND parent_conversation_id IS NOT NULL
AND (
SELECT parent.root_conversation_id
FROM conversations AS parent
WHERE parent.id = conversations.parent_conversation_id
) IS NOT NULL
"""
)
for _ in range(64):
result = bind.execute(backfill_sql)
if result.rowcount == 0:
break

Some files were not shown because too many files have changed in this diff Show More