chore(release): prepare 1.5.2 (#201)

* chore(release): prepare 1.5.2

* docs(changelog): include kanban assignee picker in 1.5.2

* fix(sessions): preserve status timers across resubscribe

* fix(sessions): use latest gateway for delayed refreshes

* fix(sessions): refresh unknown sessions via latest gateway
This commit is contained in:
Kim AI 2026-03-31 03:27:03 +03:00 committed by GitHub
parent 44403353a6
commit c9ad1bd745
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 241 additions and 40 deletions

View file

@ -23,6 +23,7 @@ AGENT_NAME=Agent
GATEWAY_TOKEN=
# OPENCLAW_GATEWAY_TOKEN= # Alternative name for GATEWAY_TOKEN (checked as fallback)
GATEWAY_URL=http://127.0.0.1:18789
# NERVE_PUBLIC_ORIGIN=https://your-nerve.example.com # Explicit browser origin for remote-workspace gateway RPC fallback
# ─── Authentication ──────────────────────────────────────────────────────────
# Enable to require a password for all API/WebSocket access.

View file

@ -6,6 +6,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
## [1.5.2] - 2026-03-30
### Highlights
**Kanban execution now matches the real session tree.** Assigned tasks launch as real child sessions beneath the selected assignee root, task completion and failures report back to the parent root, and background root notifications no longer misfire while those updates land (PR #198).
**Remote and hybrid installs are less brittle.** Nerve now supports remote-gateway installation up front via `--gateway-url`, resolves gateway RPC origins from public config for remote workspace access, and explains missing cron capability with a clear remediation path instead of a dead-end warning (PR #181, PR #197, PR #200).
**Session and agent state are less misleading.** The model picker now reflects the active OpenClaw config, duplicate root-agent creation correctly registers suffixed agents in `openclaw.json`, direct-message sessions nest under the correct agent root, and the main root label stays canonical (PR #174, PR #185, PR #192, PR #196).
**Docs and setup guidance caught back up to reality.** AI setup docs landed, setup now prints the right deployment guide links, and stale operator docs were refreshed to match the current runtime and installer behavior (PR #179, PR #182, PR #191).
### Added
- Installer support for `--gateway-url` so Nerve can target a remote gateway from first boot (PR #181)
- AI agent setup docs and a raw install contract for agent-driven installs (PR #182)
- A dedicated `GET /api/kanban/tasks/:id` endpoint for direct Kanban task lookup by id (PR #176)
- An assignee picker for Kanban task forms so users no longer need to enter raw assignee values manually (PR #203)
- Support for custom board column keys via board config (PR #173)
- Shebang-based syntax highlighting for extensionless executable files (PR #190)
### Changed
- Setup now prints deployment guide links after configuration so operators can jump straight to the right topology docs (PR #179)
- Setup now ensures `sessions_spawn` is allowlisted alongside the other required gateway tools for Kanban execution on current OpenClaw builds (PR #159)
- Model selection now comes from the active OpenClaw config instead of Nerve-side fallback lists (PR #174)
- Chat input helper text now points users at the command palette more clearly (PR #175)
### Fixed
- Skills API parsing now falls back to structured stderr JSON output when tools emit machine-readable results there (PR #161)
- Sidebar session tree cleanup: only real roots are shown, direct-message sessions nest under their owning agent root, and `agent:main:main` always renders with a canonical label (PR #177, PR #185, PR #196)
- Session selection click targets are more forgiving thanks to a small hover delay that reduces accidental steals while moving through the tree (PR #187)
- Duplicate root-agent creation now registers the correct suffixed agent in `openclaw.json` so config, workspace, and session roots stay aligned (PR #192)
- Assigned Kanban tasks now launch as real child sessions, clean up orphaned child sessions on partial launch failures, and report completion back to the parent root that owns the work (PR #198)
- Background top-level root updates now set unread state correctly and only ping on terminal events (PR #198)
- Remote-workspace gateway RPC now derives its request origin from public config instead of hardcoded loopback values, fixing hybrid/cloud `origin not allowed` failures (PR #200)
### Documentation
- Added AI setup docs and refreshed stale repo docs so installation, deployment, configuration, and troubleshooting guidance line up with the current runtime (PR #182, PR #191)
## [1.5.1] - 2026-03-25
### Fixed

View file

@ -1737,7 +1737,7 @@ Execute a task and move it to `in-progress`. The launch path depends on the task
**Response:** The updated `KanbanTask` object with `status: "in-progress"` and a `run` object.
**Execution paths:**
- **Assigned tasks** run through the assignee's live root session. Nerve verifies that parent root exists, then asks it to spawn a child worker.
- **Assigned tasks** create a real child session beneath the assignee's live root. Nerve verifies that the parent root exists, creates the child with `sessions.create(parentSessionKey=...)`, then sends the task into that child with `sessions.send`.
- **Unassigned or `operator` tasks** use the normal `sessions_spawn` path.
- **macOS fallback rule:** unassigned or `operator` tasks are rejected. Assign the task to a live worker root first.
@ -1752,6 +1752,8 @@ Execute a task and move it to `in-progress`. The launch path depends on the task
**Notes:**
- The spawned worker receives the task title and description as its prompt.
- Assigned-task runs keep both a deterministic run correlation key and the real `childSessionKey`.
- When an assigned child session finishes or fails, Nerve sends a completion report back to the parent root session.
- Backend pollers run every 5 seconds for up to **720 attempts / 60 minutes**.
- On success the task moves to `review`. On error it moves back to `todo`.

View file

@ -576,9 +576,12 @@ This prevents stale overwrites from concurrent editors (drag-and-drop, API clien
| +-- gatewayRpcCall('sessions.list', ...) confirms the parent root exists
| +-- store.executeTask(..., { sessionKey }) -> status = in-progress, run.status = running
| +-- launchKanbanFallbackSubagentViaRpc({ label, task, parentSessionKey, model?, thinking? })
| +-- gatewayRpcCall('chat.send', { sessionKey: parentSessionKey, message:'[spawn-subagent]...', idempotencyKey })
| +-- attach returned runId when available
| +-- start pollFallbackSessionCompletion(taskId, { correlationKey, parentSessionKey, expectedChildLabel, knownSessionKeysBefore, runId? })
| +-- gatewayRpcCall('sessions.create', { key: childSessionKey, parentSessionKey, label, model? })
| +-- gatewayRpcCall('sessions.send', { key: childSessionKey, message: task, thinking?, idempotencyKey })
| +-- if send fails after create: best-effort gatewayRpcCall('sessions.delete', { key: childSessionKey, deleteTranscript: true })
| +-- return correlationKey + childSessionKey + runId?
| +-- attach childSessionKey / runId immediately when available
| +-- start pollFallbackSessionCompletion(taskId, { correlationKey, parentSessionKey, childSessionKey?, expectedChildLabel, knownSessionKeysBefore, runId? })
|
+-- else if task is unassigned / operator:
| +-- on macOS: return 409 invalid_execution_target
@ -592,17 +595,16 @@ This prevents stale overwrites from concurrent editors (drag-and-drop, API clien
2. pollSessionCompletion() / pollFallbackSessionCompletion()
+-- sessions_spawn path polls gateway subagents by correlation key / childSessionKey / runId
+-- assignee-root path polls gateway RPC sessions.list every 5s (max 720 attempts / 60 min)
+-- assignee-root path matches the spawned child beneath the parent root and attaches childSessionKey
+-- both paths complete the task when the child reports terminal success/failure
+-- if session not found yet:
+-- schedule next poll
+-- if session is idle and not busy/processing:
| fetch session history (last 3 messages)
+-- assignee-root path prefers the known childSessionKey; otherwise it discovers the new child beneath the parent root and attaches it
+-- if the child completes successfully:
| fetch child history via sessions.get / sessions_history
| parseKanbanMarkers(resultText) -> create proposals
| stripKanbanMarkers(resultText) -> clean result
+-- store.completeRun(taskId, sessionKey, cleanResult)
| store.completeRun(taskId, sessionKey, cleanResult)
+-- gatewayRpcCall('sessions.send', { key: parentSessionKey, message: completionReport })
+-- if status=error/failed:
+-- store.completeRun(taskId, sessionKey, undefined, errorMsg)
| store.completeRun(taskId, sessionKey, undefined, errorMsg)
+-- gatewayRpcCall('sessions.send', { key: parentSessionKey, message: failureReport })
+-- if task/run no longer matches the active session key:
+-- stop polling as stale
+-- otherwise:
@ -613,7 +615,7 @@ This prevents stale overwrites from concurrent editors (drag-and-drop, API clien
+-- error -> run.status = error, task.status = todo
```
The model cascade is: execute request `model` -> task `model` -> board config `defaultModel` -> OpenClaw's configured default model. Thinking follows the same pattern with `defaultThinking`.
Assigned-root execution now uses real session primitives instead of synthetic marker-message spawn conventions. The model cascade is: execute request `model` -> task `model` -> board config `defaultModel` -> OpenClaw's configured default model. Thinking follows the same pattern with `defaultThinking`.
### Marker Parsing

View file

@ -109,10 +109,14 @@ HOST=127.0.0.1
|----------|---------|----------|-------------|
| `GATEWAY_TOKEN` | — | **Yes** | Authentication token for the OpenClaw gateway. The setup wizard auto-detects this. See note below |
| `GATEWAY_URL` | `http://127.0.0.1:18789` | No | Gateway HTTP endpoint URL |
| `NERVE_PUBLIC_ORIGIN` | *(empty)* | No | Explicit browser-facing Nerve origin used when server-side gateway RPC fallback must open its own WebSocket to OpenClaw. Useful for reverse-proxy, cloud, and hybrid deployments. |
```bash
GATEWAY_TOKEN=your-token-here
GATEWAY_URL=http://127.0.0.1:18789
# Optional for reverse-proxy / cloud / hybrid installs
NERVE_PUBLIC_ORIGIN=https://nerve.example.com
```
For non-interactive installs that should talk to a remote gateway, pass the URL directly to the installer:
@ -122,6 +126,8 @@ curl -fsSL https://raw.githubusercontent.com/daggerhashimoto/openclaw-nerve/mast
| bash -s -- --gateway-url https://gw.example.com --gateway-token <token> --skip-setup
```
If remote workspace panels (Files, Memory, Config, Skills) fail with `origin not allowed` while chat still works, set `NERVE_PUBLIC_ORIGIN` to the exact browser origin and add that same origin to `gateway.controlUi.allowedOrigins` on the gateway.
### Token Injection
Nerve performs **server-side token injection**. When a connection is established through the WebSocket proxy, Nerve automatically injects the configured `GATEWAY_TOKEN` into the connection request if the client is considered **trusted**.

View file

@ -86,6 +86,7 @@ In `.env`:
```bash
GATEWAY_URL=<remote-gateway-url>
WS_ALLOWED_HOSTS=<remote-gateway-hostname-or-ip>
NERVE_PUBLIC_ORIGIN=https://nerve.example.com
```
### Patch remote gateway allowed origins

View file

@ -51,7 +51,7 @@ You must:
You may apply minimal localhost-safe OpenClaw changes automatically when needed for the default local path. Examples:
- adding missing local control UI origins
- adding required gateway tool allow entries such as `cron` and `gateway`
- adding required gateway tool allow entries such as `cron`, `gateway`, and `sessions_spawn`
- fixing local device pairing or scopes needed for Nerve to connect
Ask first before any OpenClaw change that is remote, public, security-sensitive, destructive, or changes network exposure.

View file

@ -192,6 +192,20 @@ rm ~/.nerve/device-identity.json
WS_ALLOWED_HOSTS=mygateway.local npm start
```
### Workspace panels fail with `origin not allowed`
**Symptom:** Chat connects, but remote workspace panels like Files, Memory, Config, or Skills fail with gateway `origin not allowed` errors.
**Cause:** Those panels can use the server-side gateway RPC fallback, which opens its own WebSocket to the gateway. If Nerve does not know its real browser-facing origin, that fallback can present the wrong origin even though the browser chat path is already working.
**Fix:** Set the exact browser origin in `.env` and allow the same origin on the gateway:
```bash
NERVE_PUBLIC_ORIGIN=https://nerve.example.com
```
Also add `https://nerve.example.com` to `gateway.controlUi.allowedOrigins`, then restart both Nerve and the gateway.
### "device token mismatch" on WebSocket connect
**Symptom:** Server logs show `[ws-proxy] Gateway closed: code=1008, reason=unauthorized: device token mismatch`.
@ -440,28 +454,30 @@ MEMORY_PATH=/path/to/.openclaw/workspace/MEMORY.md
**Symptom:** "Timed out waiting for subagent to spawn" error.
**Cause:** Spawning uses a polling approach, sends a `[spawn-subagent]` chat message to the selected root session, then polls `sessions.list` every 1s for up to 60s waiting for a new subagent session to appear.
**Cause:** Nerve requested a child session, but the gateway never surfaced a matching worker session before the timeout. Depending on the path, that usually means the selected root session could not launch the child, the normal `sessions_spawn` path failed, or the child session metadata never became visible to Nerve's poller.
**Fix:**
- The selected root agent must be running and able to process the spawn request
- Check that the selected root session isn't busy with another task
- Check gateway logs for spawn errors
- Make sure the selected root agent exists and is healthy
- Check whether that root is already busy with another task
- Inspect gateway logs for `sessions.create`, `sessions.send`, or `sessions_spawn` failures
- Refresh sessions and retry after gateway reconnects if the session tree looks stale
### Kanban task execution cannot attach to a spawned worker
### Kanban task execution cannot attach to a worker
**Symptom:** A Kanban task enters `in-progress`, but the worker session never links up cleanly, or completion only works by label fallback.
**Symptom:** A Kanban task enters `in-progress`, but no worker session links up cleanly, the task never reaches `review`, or the parent root never gets the completion update.
**Cause:** Kanban has two execution paths now:
- **Assigned tasks** run beneath the assignee's live root session via RPC `[spawn-subagent]`.
- **Assigned tasks** create a real child session beneath the assignee's live root via `sessions.create(parentSessionKey=...)`, then send the task with `sessions.send`.
- **Unassigned or `operator` tasks** use the normal `sessions_spawn` path.
That means failures can come from either a missing assignee root, a delayed child discovery step, or the normal subagent spawn path. On macOS, unassigned or `operator` tasks are rejected outright and must be assigned to a live worker root first.
That means failures can come from a missing assignee root, a child-session create/send failure, the normal `sessions_spawn` path, or a stalled completion poller. On macOS, unassigned or `operator` tasks are rejected outright and must be assigned to a live worker root first.
**Fix:**
- If the task is assigned, make sure that assignee's root session exists and is healthy
- If the task is unassigned, verify the normal `sessions_spawn` path is working
- On macOS, assign the task to a live worker root before executing it
- Check gateway RPC/session logs for the assignee-root path, and HTTP tool logs for the normal spawn path
- If the child session finishes but the parent root never updates, inspect gateway RPC logs and recent session events for the parent-report step
- If the worker never appears in the session list, inspect gateway connectivity and recent session events first
### Session status stuck on "THINKING"

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "openclaw-nerve",
"version": "1.5.1",
"version": "1.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openclaw-nerve",
"version": "1.5.1",
"version": "1.5.2",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "openclaw-nerve",
"version": "1.5.1",
"version": "1.5.2",
"description": "Web interface for OpenClaw — chat, voice input, TTS, and agent monitoring in the browser",
"license": "MIT",
"engines": {

View file

@ -7,6 +7,8 @@ const mockUseGateway = vi.fn();
const mockUseSettings = vi.fn();
const playPingMock = vi.fn();
let rpcMock: ReturnType<typeof vi.fn>;
let subscribeMock: ReturnType<typeof vi.fn>;
let connectionStateValue: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' = 'connected';
let subscribedHandler: ((msg: GatewayEvent) => void) | null = null;
let soundEnabledValue = true;
@ -70,6 +72,7 @@ describe('SessionContext', () => {
vi.clearAllMocks();
subscribedHandler = null;
soundEnabledValue = true;
connectionStateValue = 'connected';
rpcMock = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'sessions.list') {
@ -90,15 +93,17 @@ describe('SessionContext', () => {
return {};
});
mockUseGateway.mockReturnValue({
connectionState: 'connected',
rpc: rpcMock,
subscribe: vi.fn((handler: (msg: GatewayEvent) => void) => {
subscribedHandler = handler;
return () => {};
}),
subscribeMock = vi.fn((handler: (msg: GatewayEvent) => void) => {
subscribedHandler = handler;
return () => {};
});
mockUseGateway.mockImplementation(() => ({
connectionState: connectionStateValue,
rpc: rpcMock,
subscribe: subscribeMock,
}));
mockUseSettings.mockImplementation(() => ({
soundEnabled: soundEnabledValue,
}));
@ -377,10 +382,129 @@ describe('SessionContext', () => {
});
await act(async () => {
vi.advanceTimersByTime(3_100);
await Promise.resolve();
await vi.advanceTimersByTimeAsync(3_100);
});
expect(screen.getByTestId('reviewer-status').textContent).toBe('IDLE');
});
it('uses the latest refresh callback for delayed refreshes after gateway changes', async () => {
const rpcBeforeReconnect = vi.fn(async (method: string) => {
if (method === 'sessions.list') {
return {
sessions: [
{ sessionKey: 'agent:main:main', label: 'Main' },
{ sessionKey: 'agent:reviewer:main', label: 'Reviewer' },
],
};
}
return {};
});
const rpcAfterReconnect = vi.fn(async () => ({}));
rpcMock = rpcBeforeReconnect;
const view = render(
<SessionProvider>
<SessionStatusProbe />
</SessionProvider>,
);
await waitFor(() => {
expect(rpcBeforeReconnect).toHaveBeenCalledWith('sessions.list', { limit: 1000 });
});
vi.useFakeTimers();
await act(async () => {
subscribedHandler?.({
type: 'event',
event: 'chat',
payload: {
sessionKey: 'agent:reviewer:main',
state: 'final',
},
});
await Promise.resolve();
});
const preReconnectSessionsListCalls = rpcBeforeReconnect.mock.calls.filter(([method]) => method === 'sessions.list').length;
await act(async () => {
connectionStateValue = 'reconnecting';
rpcMock = rpcAfterReconnect;
view.rerender(
<SessionProvider>
<SessionStatusProbe />
</SessionProvider>,
);
await Promise.resolve();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(1_600);
});
expect(rpcBeforeReconnect.mock.calls.filter(([method]) => method === 'sessions.list')).toHaveLength(preReconnectSessionsListCalls);
expect(rpcAfterReconnect).not.toHaveBeenCalledWith('sessions.list', expect.anything());
});
it('uses the latest refresh callback for missing-session fallback refreshes after gateway changes', async () => {
const rpcBeforeReconnect = vi.fn(async (method: string) => {
if (method === 'sessions.list') {
return {
sessions: [
{ sessionKey: 'agent:main:main', label: 'Main' },
{ sessionKey: 'agent:reviewer:main', label: 'Reviewer' },
],
};
}
return {};
});
const rpcAfterReconnect = vi.fn(async () => ({}));
rpcMock = rpcBeforeReconnect;
const view = render(
<SessionProvider>
<SessionStatusProbe />
</SessionProvider>,
);
await waitFor(() => {
expect(rpcBeforeReconnect).toHaveBeenCalledWith('sessions.list', { limit: 1000 });
});
vi.useFakeTimers();
await act(async () => {
subscribedHandler?.({
type: 'event',
event: 'chat',
payload: {
sessionKey: 'agent:fresh:main',
state: 'started',
},
});
await Promise.resolve();
});
const preReconnectSessionsListCalls = rpcBeforeReconnect.mock.calls.filter(([method]) => method === 'sessions.list').length;
await act(async () => {
connectionStateValue = 'reconnecting';
rpcMock = rpcAfterReconnect;
view.rerender(
<SessionProvider>
<SessionStatusProbe />
</SessionProvider>,
);
await Promise.resolve();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(150);
});
expect(rpcBeforeReconnect.mock.calls.filter(([method]) => method === 'sessions.list')).toHaveLength(preReconnectSessionsListCalls);
expect(rpcAfterReconnect).not.toHaveBeenCalledWith('sessions.list', expect.anything());
});
});

View file

@ -497,6 +497,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
}
}, [connectionState, listAuthoritativeSessions, setCurrentSession]);
const refreshSessionsRef = useRef(refreshSessions);
useEffect(() => {
refreshSessionsRef.current = refreshSessions;
}, [refreshSessions]);
// Update session in list from WebSocket event data
const updateSessionFromEvent = useCallback((sessionKey: string, updates: Partial<Session>) => {
setSessions(prev => {
@ -504,7 +509,9 @@ export function SessionProvider({ children }: { children: ReactNode }) {
if (idx === -1) {
// New session appeared that we don't have - schedule a refresh
// Use setTimeout to avoid calling during render
setTimeout(() => refreshSessions(), 100);
setTimeout(() => {
void refreshSessionsRef.current();
}, 100);
return prev;
}
@ -523,7 +530,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
return { ...s, ...updates, lastActivity: Date.now() };
});
});
}, [refreshSessions]);
}, []);
// Extract session updates (state + token data) from a typed agent event payload
const extractSessionUpdates = useCallback((state: string | undefined, payload: AgentEventPayload | ChatEventPayload): Partial<Session> => {
@ -540,9 +547,9 @@ export function SessionProvider({ children }: { children: ReactNode }) {
}
delayedRefreshTimeoutRef.current = setTimeout(() => {
delayedRefreshTimeoutRef.current = null;
refreshSessions();
void refreshSessionsRef.current();
}, 1500);
}, [refreshSessions]);
}, []);
// Subscribe to gateway events for granular status tracking + session state sync + agent log + event log
useEffect(() => {
@ -664,9 +671,13 @@ export function SessionProvider({ children }: { children: ReactNode }) {
feedAgentLog(evt, p);
});
// Cleanup: cancel all pending DONE→IDLE timeouts
return () => {
unsub();
};
}, [subscribe, addEvent, setGranularStatus, markSessionUnread, pingSession, feedAgentLog, updateSessionFromEvent, extractSessionUpdates, refreshSessions, scheduleDelayedRefresh]);
useEffect(() => {
return () => {
for (const key of Object.keys(doneTimeoutsRef.current)) {
clearTimeout(doneTimeoutsRef.current[key]);
}
@ -676,7 +687,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
delayedRefreshTimeoutRef.current = null;
}
};
}, [subscribe, addEvent, setGranularStatus, markSessionUnread, pingSession, feedAgentLog, updateSessionFromEvent, extractSessionUpdates, refreshSessions, scheduleDelayedRefresh]);
}, []);
// Poll sessions when connected (reduced to 30s - WebSocket events provide real-time updates)
useEffect(() => {