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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ See [Setup](docs/SETUP.md) for configuration, Slack, calls, the browser service,
| Automatic Learning | Per-Dot Learning containers, conversation evidence routing, and published-skill delivery; see [setup](docs/SETUP.md#automatic-learning) |
| Deployment | Local Node setup and separate application/browser containers |

Scheduled tasks run in their original conversation. If a worker stops or its lease expires during a run, OpenDots marks that run **Interrupted** and waits for an explicit retry. Review its pages and computer actions, then use **Retry after review** when appropriate. Completed effects may already be present even when a run has no final result.

Local checks cover setup, persistence, permissions, SDK failure handling, and browser isolation. Automated tests use service fixtures. **Live Intelligence, model responses, and page-context chat were verified on September 29, 2026.** Live OpenBot computer browsing, file creation, shell verification, and file persistence across stop/start were also verified locally. Live Realtime speech, call controls, and receipt persistence were verified locally on September 30, 2026. Slack and spoken compute delegation still need connected-service verification. See [recording notes](docs/demos/README.md) for the demonstrated flows and limits.

Automatic Learning routing and skill delivery are configured locally. Cloud schedules, eligible-thread counts, and published-skill delivery still need connected-service verification. Skills require review and publication in Intelligence; existing conversations without a container are not enrolled retroactively.
Expand Down
12 changes: 7 additions & 5 deletions src/client/TaskActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ export function TaskActions({
onClick={() => onAction('run')}
>
<Play size={14} />
{task.status === 'failed'
? 'Retry task'
: task.status === 'paused'
? 'Resume task'
: 'Run again'}
{task.status === 'interrupted'
? 'Retry after review'
: task.status === 'failed'
? 'Retry task'
: task.status === 'paused'
? 'Resume task'
: 'Run again'}
</button>
)}
{task.status === 'completed' && !!task.intervalSeconds && (
Expand Down
3 changes: 2 additions & 1 deletion src/client/WorkspaceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,8 @@ export function WorkspaceDialog({
</select>
<p className="muted">
Runs on the server in this same conversation, even with the tab
closed. Failed runs wait for manual retry.
closed. Failed or interrupted runs wait for manual retry. Review
completed work before retrying an interrupted run.
</p>
</>
)}
Expand Down
3 changes: 2 additions & 1 deletion src/client/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,8 @@ h3 {
color: #788bc3;
background: #edf0f9;
}
.status.failed {
.status.failed,
.status.interrupted {
color: #b17d73;
background: #faf0ed;
}
Expand Down
4 changes: 2 additions & 2 deletions src/server/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ export class Runner {
this.timer = undefined;
for (const task of this.store.tasks()) {
if (this.active.has(task.id) && task.lease)
this.store.release(
this.store.interrupt(
{ ...task, lease: task.lease },
'Server stopping; queued for restart.',
'Server stopped during this run. Review completed effects before retrying.',
);
}
this.abortAll();
Expand Down
16 changes: 8 additions & 8 deletions src/server/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ export class Store {
for (const task of running) {
this.invalidate(
task,
'queued',
'Run stopped because settings changed.',
'interrupted',
'Run interrupted because settings changed. Review completed effects before retrying.',
);
}
}
Expand Down Expand Up @@ -141,9 +141,9 @@ export class Store {
.run(now, reason, task.lease);
this.db
.prepare(
'UPDATE tasks SET status=?, lease=NULL, leaseUntil=NULL, updatedAt=? WHERE id=?',
'UPDATE tasks SET status=?, lease=NULL, leaseUntil=NULL, updatedAt=?, error=? WHERE id=?',
)
.run(status, now, task.id);
.run(status, now, status === 'interrupted' ? reason : null, task.id);
this.event(task.id, task.lease, reason);
}
action(id: string, action: Action): Task | undefined {
Expand Down Expand Up @@ -199,8 +199,8 @@ export class Store {
for (const task of expired)
this.invalidate(
task,
'queued',
'Previous worker lease expired; safely retrying.',
'interrupted',
'Worker lease expired. Review completed effects before retrying.',
);
const task = this.db
.prepare(
Expand Down Expand Up @@ -255,9 +255,9 @@ export class Store {
return true;
});
}
release(claim: Claim, reason: string) {
interrupt(claim: Claim, reason: string) {
this.transaction(() => {
if (this.owns(claim)) this.invalidate(claim, 'queued', reason);
if (this.owns(claim)) this.invalidate(claim, 'interrupted', reason);
});
}
fail(claim: Claim, error: string) {
Expand Down
8 changes: 7 additions & 1 deletion src/shared/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
export type Status =
'queued' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
| 'queued'
| 'running'
| 'paused'
| 'completed'
| 'failed'
| 'interrupted'
| 'cancelled';
export interface Settings {
name: string;
paused: boolean;
Expand Down
13 changes: 13 additions & 0 deletions tests/controls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,16 @@ it('offers resume for a paused task without claiming to be running', () => {
expect(html).toContain('Resume task');
expect(html).not.toContain('Pause schedule');
});
it('labels an interrupted task for owner review before retry', () => {
const html = renderToStaticMarkup(
<TaskActions
task={{ ...task, status: 'interrupted' }}
settings={settings}
busy={false}
onAction={() => {}}
onSchedule={() => {}}
/>,
);
expect(html).toContain('Retry after review');
expect(html).not.toContain('Pause task');
});
8 changes: 5 additions & 3 deletions tests/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ it('aborts research when permissions are revoked outside the runner instance', a
await tick;
expect(requestSignal?.aborted).toBe(true);
expect(request).toHaveBeenCalledOnce();
expect(store.tasks()[0].status).toBe('queued');
expect(store.tasks()[0].status).toBe('interrupted');
runner.stop();
store.close();
});
Expand Down Expand Up @@ -73,7 +73,7 @@ it('omits stored memories from research when memory permission is disabled', asy
);
store.close();
});
it('requeues active work on graceful shutdown instead of losing it', async () => {
it('holds active work for review on graceful shutdown', async () => {
const store = new Store(':memory:');
const runner = new Runner(store, config);
const fetch = vi.fn(
Expand All @@ -92,7 +92,9 @@ it('requeues active work on graceful shutdown instead of losing it', async () =>
await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
runner.stop();
await pending;
expect(store.tasks()[0].status).toBe('queued');
expect(store.tasks()[0].status).toBe('interrupted');
expect(store.claim()).toBeNull();
store.action(store.tasks()[0].id, 'run');
expect(store.claim()).toBeTruthy();
store.close();
});
81 changes: 72 additions & 9 deletions tests/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/server/store.js';
import { WorkspaceStore } from '../src/server/workspace.js';
import { pageAccess } from '../src/server/page-tools.js';

const resources: { store: Store; dir: string }[] = [];
function fixture() {
Expand Down Expand Up @@ -71,21 +73,82 @@ describe('durable task lifecycle', () => {
store.finish(claim, { text: 'Late result', sources: [], sample: true }),
).toBe(false);
store.updateSettings({ paused: false });
expect(store.claim(Date.now())).toBeNull();
expect(store.task(task.id)?.status).toBe('interrupted');
store.action(task.id, 'run');
expect(store.claim(Date.now())?.id).toBe(task.id);
});
it('recovers expired work after restart without duplicate completion', () => {
const { store } = fixture();
it('holds expired work until the owner retries it', () => {
const { store, path } = fixture();
store.createTask('Recover me');
const now = Date.now();
const old = store.claim(now)!;
const recovered = store.claim(now + 180_001)!;
expect(recovered.id).toBe(old.id);
expect(recovered.lease).not.toBe(old.lease);
expect(store.finish(old, { text: 'Old', sources: [], sample: true })).toBe(
const restarted = new Store(path);
try {
expect(restarted.claim(now + 180_001)).toBeNull();
expect(restarted.task(old.id)?.status).toBe('interrupted');
expect(restarted.detail(old.id)?.runs[0].status).toBe('interrupted');
restarted.action(old.id, 'run');
const recovered = restarted.claim(now + 180_001)!;
expect(recovered.id).toBe(old.id);
expect(recovered.lease).not.toBe(old.lease);
expect(
store.finish(old, { text: 'Old', sources: [], sample: true }),
).toBe(false);
expect(
restarted.finish(recovered, {
text: 'New',
sources: [],
sample: true,
}),
).toBe(true);
} finally {
restarted.close();
}
});
it('claims other queued work while an expired run waits for review', () => {
const { store } = fixture();
const interrupted = store.createTask('Create the first page');
const now = Date.now();
const old = store.claim(now)!;
const next = store.createTask('Research another topic');

expect(store.claim(now + 180_001)?.id).toBe(next.id);
expect(store.task(interrupted.id)?.status).toBe('interrupted');
expect(store.detail(interrupted.id)?.runs[0].status).toBe('interrupted');
expect(store.finish(old, { text: 'Late', sources: [], sample: true })).toBe(
false,
);
expect(
store.finish(recovered, { text: 'New', sources: [], sample: true }),
).toBe(true);
});
it('holds an expired run for review after a local page effect', () => {
const { store, path } = fixture();
const workspace = new WorkspaceStore(path, 'synthetic-owner');
try {
const dot = workspace.dots()[0];
const thread = workspace.bindThread(
'scheduled-thread',
dot.id,
'Scheduled work',
);
const task = store.createTask('Create the sample page once');
workspace.bindTask(task.id, thread.id);
const now = Date.now();
const old = store.claim(now)!;
const pages = pageAccess(workspace, dot.spaceId, thread.id, () => {});
pages.create({ title: 'Sample', content: 'Synthetic fixture body' });

expect(store.claim(now + 180_001)).toBeNull();
expect(store.task(task.id)?.status).toBe('interrupted');
expect(store.detail(task.id)?.runs[0].status).toBe('interrupted');
expect(workspace.pages.list(dot.spaceId)).toHaveLength(1);
expect(
store.finish(old, { text: 'Late', sources: [], sample: true }),
).toBe(false);

store.action(task.id, 'run');
expect(store.claim(now + 180_002)?.id).toBe(task.id);
} finally {
workspace.close();
}
});
});
Loading