Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 51 additions & 4 deletions .github/workflows/mac-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,19 @@ name: Mac release

on:
workflow_dispatch:

inputs:
version:
description: "Marketing version to publish, e.g. 1.0.0. Leave empty to build without publishing."
required: false
default: ""
prerelease:
description: "Mark the GitHub release as a pre-release"
type: boolean
default: false

# Write, for the one step that creates the GitHub release. Nothing else here pushes.
permissions:
contents: read
contents: write

# Hoisted to the job so the optional steps can be gated on them.
#
Expand All @@ -16,6 +26,9 @@ env:
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }}
SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }}
XBOT_VERSION: ${{ inputs.version }}
# Sparkle compares CFBundleVersion, so it must rise with every release. The run number does.
XBOT_BUILD_NUMBER: ${{ github.run_number }}

jobs:
build:
Expand Down Expand Up @@ -48,7 +61,7 @@ jobs:

- name: Bundle .app
env:
XBOT_APPCAST_URL: ${{ secrets.XBOT_APPCAST_URL }}
XBOT_APPCAST_URL: ${{ inputs.version != '' && format('https://github.com/{0}/releases/latest/download/appcast.xml', github.repository) || secrets.XBOT_APPCAST_URL }}
XBOT_SPARKLE_PUBLIC_KEY: ${{ secrets.XBOT_SPARKLE_PUBLIC_KEY }}
run: scripts/bundle-mac-app.sh

Expand Down Expand Up @@ -110,10 +123,13 @@ jobs:
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
run: scripts/sign-mac-app.sh

# The enclosure has to point at the asset this run is about to upload. The feed itself is
# served from releases/latest/download/appcast.xml, which GitHub redirects to the newest
# release — so each release's appcast only needs to describe itself.
- name: Generate Sparkle appcast
if: env.SPARKLE_EDDSA_PRIVATE_KEY != ''
env:
XBOT_RELEASE_DOWNLOAD_PREFIX: ${{ secrets.XBOT_RELEASE_DOWNLOAD_PREFIX }}
XBOT_RELEASE_DOWNLOAD_PREFIX: ${{ inputs.version != '' && format('https://github.com/{0}/releases/download/v{1}/', github.repository, inputs.version) || secrets.XBOT_RELEASE_DOWNLOAD_PREFIX }}
run: scripts/generate-appcast.sh

- uses: actions/upload-artifact@v4
Expand All @@ -122,3 +138,34 @@ jobs:
path: |
dist/xBot.dmg
dist/releases/

# Only a signed, notarized build is published: an unsigned DMG on the download page is the
# "cannot be opened" dialog that stops a non-technical person for good.
- name: Verify what a stranger's Mac will check
if: inputs.version != ''
run: |
set -euo pipefail
mnt="$(mktemp -d)"
hdiutil attach -nobrowse -readonly -mountpoint "${mnt}" dist/xBot.dmg
trap 'hdiutil detach "${mnt}" >/dev/null' EXIT
spctl -a -vvv -t execute "${mnt}/XBot.app" 2>&1 | tee /dev/stderr | grep -q "Notarized Developer ID"
xcrun stapler validate "${mnt}/XBot.app"
xcrun stapler validate dist/xBot.dmg
test -f dist/releases/appcast.xml
grep -q 'sparkle:edSignature=' dist/releases/appcast.xml
test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "${mnt}/XBot.app/Contents/Info.plist")" = "${XBOT_VERSION}"

- name: Publish GitHub release
if: inputs.version != ''
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag="v${XBOT_VERSION}"
flags=()
[[ "${{ inputs.prerelease }}" == "true" ]] && flags+=(--prerelease) || flags+=(--latest)
gh release create "${tag}" --repo "${GITHUB_REPOSITORY}" --target "${GITHUB_SHA}" \
--title "xBot ${XBOT_VERSION}" --generate-notes "${flags[@]}" \
"dist/xBot.dmg#xBot.dmg" \
"dist/releases/xBot-${XBOT_VERSION}.dmg" \
"dist/releases/appcast.xml"
16 changes: 7 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ finished. Route it through the app.
### Non-goals

- No hosted/SaaS version of xBot. The app, the engine, the agents and their browsers run on the
user's machine. **In v1 the conversation transcript is the exception** — it lives in CopilotKit
Intelligence, and onboarding says so before the user types a key. See ADR-0007.
user's machine, and so are conversations: the engine keeps them itself. See ADR-0008.
- No Mac App Store build. See `docs/decisions/0005-distribution-outside-app-store.md`.
- No Windows or Linux client in v1. The engine is portable; the client is not.
- No model of our own. xBot supplies no intelligence — the user brings keys or runs Ollama.
Expand Down Expand Up @@ -79,21 +78,20 @@ it — it usually does, and better than a first attempt would.
- New xBot code goes in **new files**. A moved or heavily edited upstream file is a permanent merge
conflict.

### 1. v1 runs on CopilotKit Intelligence, and the seam to leave it is already built.
### 1. v1 runs in local mode, on the vendor's own SSE runner made durable.

OpenBot's `runtimeCapabilities()` in `server/src/config.ts` used to **throw on startup** unless all
four of `INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY` and
`COPILOTKIT_LICENSE_TOKEN` were set. It now selects a mode: all four means Intelligence, **none**
means local history, and a partial set still throws — for upstream's original reason, that somebody
who set two of four intended Intelligence and got it wrong.

**v1 sets all four.** The local mode exists, boots, and serves, but `LocalIntelligence` is a spike
that records which methods are reached and throws; it is not a provider yet.
**v1 sets none.** Local mode runs `LocalThreadRunner` (`server/src/history/local-thread-runner.ts`),
which extends CopilotKit's `InMemoryAgentRunner` and persists each thread to `local_threads`. The
app has no field for a CopilotKit key. Recall/memory (pgvector) is still ADR-0001's, for v1.1.

- ADRs: `docs/decisions/0007-...` (the decision), `docs/decisions/0001-...` (the eventual design)
- Measured scope: `grep` says 156 references across 18 files; the **compiler** says 5 across 4.
Trust the compiler. Widen the `RuntimeCapabilities` union and let it tell you.
- `COPILOTKIT_LICENSE_TOKEN` is telemetry only. The runtime validates nothing.
- ADRs: `docs/decisions/0008-...` (what shipped), `0007-...` (the seam), `0001-...` (the fuller design)
- `LocalThreadRunner` is single-user: SSE mode has no `identifyUser`.
- **Never add a new direct call to the Intelligence client.** Go through the seam.

### 2. The model provider is a process-wide environment variable. We are making it per-agent.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ process. On 27 September a throwaway engine with no CopilotKit key held a three-
with a local Ollama model, drove its browser through the client-tool loop, remembered across turns,
and kept every conversation intact through a restart.

Still open: a second live vendor, one intermittent fault seen only under the full live suite, a
clean-VM first run, and Developer ID signing, notarization, Sparkle keys and the first published
release. Those last items need an account holder.
Still open: a second live vendor, a clean-VM first run, notarization credentials that Apple
accepts, and the first published release. Signing, Sparkle keys and the release pipeline are in
place.

Start at [`docs/README.md`](docs/README.md). The milestone table is in
[`docs/12-roadmap.md`](docs/12-roadmap.md), and what is left, in order, is in
Expand Down
12 changes: 6 additions & 6 deletions docs/01-vision.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,12 @@ is genuinely local and genuinely model-agnostic.

Recorded here rather than discovered in month four.

**⚠️ The engine has a cloud dependency we are living with in v1.** OpenBot's server refuses to
start without CopilotKit Intelligence unless the seam described in
[ADR-0007](decisions/0007-wrap-openbot-keep-intelligence.md) is used. It is built and the engine
has been run without an account — but v1 ships on Intelligence, so a third party can change its
free tier and affect the product. The seam is what keeps that from being fatal rather than
inconvenient. [ADR-0001](decisions/0001-local-history-provider.md) remains the end state.
**The engine's cloud dependency is gone for conversations.** OpenBot's server refused to start
without CopilotKit Intelligence; the seam from
[ADR-0007](decisions/0007-wrap-openbot-keep-intelligence.md) lets it run without, and
[ADR-0008](decisions/0008-local-thread-runner.md) made local mode the one v1 ships: conversations
are kept by the engine's own `LocalThreadRunner`. Cross-conversation memory (pgvector) is still
[ADR-0001](decisions/0001-local-history-provider.md)'s, for v1.1.

**⚠️ Container runtimes on macOS are a licensing and UX minefield.** Docker Desktop requires a paid
licence above a company-size threshold, is a large install, and is not something we can bundle.
Expand Down
21 changes: 12 additions & 9 deletions docs/13-launch-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,16 @@ That first run found three engine faults, each of which failed every conversatio
guidance, and Anthropic accepts a system message only first. All system texts are now merged, in
order, into one leading message (`agent-langgraph/src/history.ts`).

**One open finding.** On a freshly started engine, roughly one full live run in six had a turn come
back empty, or a request go unanswered, while all nine live tests ran at once. The engine logged
"Thread already running" or, once, "No agents are registered", both of which should be impossible
there. It was never seen with the conversation suite alone (5 of 5 fresh engines), nor with a
logging proxy in the path (12 of 12). A leftover process, a database lock, identity, idle
keep-alive and split request writes were each checked and ruled out. Worth one more look before
launch, starting from the app under ordinary use rather than nine tests at once.
**The intermittent fault, found and fixed** (29 September). On a freshly started engine, roughly one
full live run in six had a turn come back empty, followed by "Thread already running". The cause was
Bun's own `idleTimeout`, 10 seconds by default: a streaming run writes RUN_STARTED and then nothing
until the model's first token, and a cold local model takes longer than that. The connection was
closed under a live run, the reply was empty, and the orphaned run held the thread until it
finished. A fake model that stays silent for 15 seconds reproduced it every time. Streaming agent
runs are now exempt from the idle timeout (`server/src/xbot/stream-idle-timeout.ts`); with that, 6
of 6 full live rounds on fresh engines with a cold model passed with no busy refusals. A message
sent while a run genuinely is still going now gets RUN_ERROR with a sentence rather than an empty
200.

**Two more, found starting the app on this Mac's own engine** (27 September), both leaving the
container unhealthy with nothing on its port and no way back short of deleting the data:
Expand Down Expand Up @@ -233,8 +236,8 @@ From a shell holding no credentials:
now needs re-running against `LocalThreadRunner` rather than a CopilotKit key — see item 5.
- On 27 September, it was: a throwaway engine in local mode with no CopilotKit key held a real
conversation with a local Ollama model, used its computer through the client-tool loop, remembered
across turns, and kept every conversation intact through a restart. Item 5 has the details and
the one open finding.
across turns, and kept every conversation intact through a restart. Item 5 has the details,
including the intermittent empty reply found and fixed on 29 September.
- The pinned engine is one multi-arch image (linux/amd64 and linux/arm64). On 14 September an Apple
Silicon Mac pulled it anonymously, got arm64, was healthy in about ten seconds on a port other than
3001, and passed the live suite: browser, files, shell, and a help request handed back. Before that
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Architecture Decision Records. Each one exists because the decision looks wrong
| [0005](decisions/0005-distribution-outside-app-store.md) | Developer ID and a DMG, not the Mac App Store |
| [0006](decisions/0006-naming-and-trademark.md) | Open questions about the name and the visual reference |
| [0007](decisions/0007-wrap-openbot-keep-intelligence.md) | **Wrap OpenBot rather than re-engineer it, and keep Intelligence for v1.** Defers 0001 and re-orders the roadmap — read it before 01 or 03 |
| [0008](decisions/0008-local-thread-runner.md) | **Conversations kept locally** by a durable wrapper around the vendor's SSE runner. Supersedes 0007's "keep Intelligence" for v1 |

## Conventions in these documents

Expand Down
30 changes: 29 additions & 1 deletion engine/server/src/history/local-thread-runner.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Message } from "@ag-ui/client";
import { type BaseEvent, EventType, type Message } from "@ag-ui/client";
import {
type AgentRunnerRunRequest,
InMemoryAgentRunner,
ɵGLOBAL_STORE,
} from "@copilotkit/runtime/v2";
import { of } from "rxjs";
import type { Database } from "../db/client";
import { localThreads } from "../db/schema";
import type { IntelligenceLike } from "../routines/run-turn";
Expand Down Expand Up @@ -50,6 +52,23 @@ export class LocalThreadRunner extends InMemoryAgentRunner {
}

override run(request: AgentRunnerRunRequest) {
/*
* A conversation that is still answering refuses the next run, and says so as an event.
*
* The vendor's runner throws "Thread already running" here. Thrown inside the SSE handler's
* factory, that is logged and the response — already a 200 — is closed with no events in it, so
* the Mac app saw a reply that was simply empty and could not tell anybody why. RUN_ERROR is the
* event every caller already reads as a failed turn: the app shows its sentence, and a routine or
* a hop, which take a lock before they get here, treat it as the refusal it is.
*/
if (this.isBusy(request.threadId)) {
return of<BaseEvent>({
type: EventType.RUN_ERROR,
message:
"The agent is still answering your last message in this conversation. Wait for it to finish, then send this again.",
code: "thread_busy",
} as BaseEvent);
}
const carried = request.agent.messages;
const before = new Set(carried.map((message) => message.id));
const shown = request.persistedInputMessages ?? carried;
Expand All @@ -68,6 +87,15 @@ export class LocalThreadRunner extends InMemoryAgentRunner {
return [...(this.kept.get(threadId) ?? [])];
}

/**
* Whether a run would be refused. The vendor's own test, read synchronously from its store
* because `run` must answer synchronously, and `isRunning` is a promise.
*/
private isBusy(threadId: string): boolean {
const store = ɵGLOBAL_STORE.peek(threadId);
return Boolean(store?.isRunning || store?.stopRequested);
}

private keep(threadId: string, agentId: string, messages: Message[]) {
const prior = this.kept.get(threadId) ?? [];
const known = new Set(prior.map((message) => message.id));
Expand Down
3 changes: 3 additions & 0 deletions engine/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import {
startWorkOfferedListener,
type WorkOfferedListener,
} from "./work/queue";
import { keepStreamingRunsOpen } from "./xbot/stream-idle-timeout";

/**
* Who is asking, for a CopilotKit request.
Expand Down Expand Up @@ -1164,6 +1165,8 @@ const asChannelSocket = (ws: { data: SocketData }) =>
serve<SocketData>({
port,
async fetch(request, server) {
// xBot: before anything is awaited. See xbot/stream-idle-timeout.ts.
keepStreamingRunsOpen(request, server);
const url = new URL(request.url);
const streamBotId = streamPathBotId(url.pathname);
if (
Expand Down
35 changes: 35 additions & 0 deletions engine/server/src/xbot/stream-idle-timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Server } from "bun";

/**
* Streaming agent runs are exempt from Bun's idle timeout. Nothing else is.
*
* `Bun.serve` closes a connection that has written nothing for `idleTimeout` seconds, 10 by default.
* A run over SSE writes RUN_STARTED and then nothing until the model's first token, and a model is
* routinely silent for longer than that: a local model loading into memory, a request queued behind
* another run on the same Ollama, a vendor under load, a long think before a tool call. The stream
* was then closed under a run that was still going. The person saw an empty reply, the run finished
* in the engine with nobody listening, and the next message on that conversation was refused with
* "Thread already running" until it did.
*
* Only these requests, rather than the server's timeout raised for everything: the timeout is what
* frees a connection a client abandoned without closing, and every other route answers in one go.
* A run that genuinely stops producing is the stall guard's to end (AGENT_STALL_TIMEOUT_MS), which
* says so to the person rather than dropping the connection.
*/
const AGENT_STREAM = /^\/api\/copilotkit\/agent\/[^/]+\/(run|connect)$/;

export function holdsLongSilences(request: Request): boolean {
if (request.method !== "POST") return false;
if (!request.headers.get("accept")?.includes("text/event-stream")) {
return false;
}
return AGENT_STREAM.test(new URL(request.url).pathname);
}

/** Call first thing in `fetch`, before anything is awaited. `0` is Bun's "no idle timeout". */
export function keepStreamingRunsOpen(
request: Request,
server: Pick<Server<unknown>, "timeout">,
): void {
if (holdsLongSilences(request)) server.timeout(request, 0);
}
78 changes: 77 additions & 1 deletion engine/server/tests/local-thread-runner.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import type { BaseEvent, Message, RunAgentInput } from "@ag-ui/client";
import { AbstractAgent, EventType } from "@ag-ui/client";
import { eq } from "drizzle-orm";
import { lastValueFrom, Observable } from "rxjs";
import { lastValueFrom, Observable, Subject } from "rxjs";
import { createDatabase } from "../src/db/client";
import { localThreads } from "../src/db/schema";
import {
Expand Down Expand Up @@ -189,3 +189,79 @@ describe("a conversation kept locally", () => {
]);
});
});

/** Says nothing until released, so a second run can arrive while it is still going. */
class Held extends AbstractAgent {
private readonly gate = new Subject<void>();
release() {
this.gate.next();
this.gate.complete();
}
run(input: RunAgentInput) {
return new Observable<BaseEvent>((subscriber) => {
subscriber.next({
type: EventType.RUN_STARTED,
threadId: input.threadId,
runId: input.runId,
} as BaseEvent);
// Whichever subscription the runner holds, and however many times it subscribes.
const opened = this.gate.subscribe({
complete: () => {
subscriber.next({
type: EventType.RUN_FINISHED,
threadId: input.threadId,
runId: input.runId,
} as BaseEvent);
subscriber.complete();
},
});
return () => opened.unsubscribe();
});
}
}

describe("a message sent while the conversation is still answering", () => {
const input = (threadId: string) => ({
threadId,
runId: randomUUID(),
messages: [user(randomUUID(), "hello")],
state: {},
tools: [],
context: [],
forwardedProps: {},
});

test("is refused with a RUN_ERROR a person can read, not a silent empty stream", async () => {
const threadId = newThread();
const runner = new LocalThreadRunner(database);
const first = new Held();
first.setMessages([]);
const running = lastValueFrom(
runner.run({ threadId, agent: first, input: input(threadId) }),
{ defaultValue: undefined },
);

// Thrown, the vendor's SSE handler logs it and closes a 200 with no events in it at all.
const second = new Echo();
second.setMessages([]);
const events: BaseEvent[] = [];
await new Promise<void>((resolve, reject) =>
runner
.run({ threadId, agent: second, input: input(threadId) })
.subscribe({
next: (e) => events.push(e),
error: reject,
complete: resolve,
}),
);

expect(events.map((event) => event.type)).toEqual([EventType.RUN_ERROR]);
expect((events[0] as { message?: string }).message).toContain(
"still answering",
);

first.release();
await running;
expect(await runner.isRunning({ threadId })).toBe(false);
});
});
Loading
Loading