@ixo/oracle-runtime-workers 0.21.2 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +16 -13
  2. package/package.json +10 -4
  3. package/src/action-proposals/policy.ts +54 -0
  4. package/src/action-proposals/reconcile.ts +87 -0
  5. package/src/action-proposals/store.ts +863 -0
  6. package/src/action-proposals/topic-budget.ts +84 -0
  7. package/src/action-proposals/topic-chain.ts +272 -0
  8. package/src/action-proposals/topic-effects.ts +44 -0
  9. package/src/action-proposals/topic-errors.ts +79 -0
  10. package/src/action-proposals/topic-history.ts +267 -0
  11. package/src/action-proposals/topic-matrix.ts +292 -0
  12. package/src/action-proposals/topic-prepare.ts +299 -0
  13. package/src/action-proposals/topic-store.ts +999 -0
  14. package/src/action-proposals/topic-verify.ts +279 -0
  15. package/src/action-proposals/verify.ts +223 -0
  16. package/src/channels/auth.ts +6 -1
  17. package/src/channels/contract.ts +4 -0
  18. package/src/channels/turns.ts +1 -0
  19. package/src/core/env.ts +107 -5
  20. package/src/core/index.ts +8 -2
  21. package/src/core/main-agent-types.ts +16 -0
  22. package/src/core/main-agent.ts +26 -0
  23. package/src/core/middlewares/tool-execution.ts +87 -46
  24. package/src/core/middlewares/tool-marks.ts +54 -19
  25. package/src/core/runtime-context.ts +27 -1
  26. package/src/core/state.ts +2 -0
  27. package/src/core/tool-scheduler.ts +129 -24
  28. package/src/delivery/plan.ts +43 -2
  29. package/src/delivery/schema.ts +25 -1
  30. package/src/delivery/types.ts +13 -2
  31. package/src/do/active-runs.ts +171 -0
  32. package/src/do/ambient.ts +6 -0
  33. package/src/do/contracts.ts +86 -3
  34. package/src/do/run-coordinator.ts +30 -0
  35. package/src/do/run-request.ts +15 -2
  36. package/src/do/run-store-test-do.ts +10 -0
  37. package/src/do/run-store.ts +66 -13
  38. package/src/do/turn-body.ts +13 -1
  39. package/src/do/user-oracle-do.ts +1420 -17
  40. package/src/hub-actions/client.ts +163 -0
  41. package/src/hub-actions/request.ts +110 -0
  42. package/src/hub-actions/store.ts +485 -0
  43. package/src/matrix/gateway-do.ts +176 -0
  44. package/src/matrix/reply-parts.ts +8 -2
  45. package/src/plugin-api/tool-helper.ts +6 -0
  46. package/src/plugin-api/types.ts +117 -7
  47. package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
  48. package/src/plugins/action-proposals/evidence.ts +67 -0
  49. package/src/plugins/agui/agui.plugin.ts +7 -5
  50. package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
  51. package/src/plugins/index.ts +6 -0
  52. package/src/plugins/ixo-transaction/wallet-signing.ts +5 -2
  53. package/src/plugins/portal/declared-tools.ts +12 -0
  54. package/src/plugins/portal/portal.plugin.ts +22 -8
  55. package/src/realtime/frontend-call-registry.ts +147 -27
  56. package/src/realtime/realtime-endpoint.ts +335 -41
  57. package/src/realtime/session-socket-hub.ts +90 -19
  58. package/src/shell/app.ts +218 -2
  59. package/src/sqlite/sessions-store.ts +22 -0
@@ -1,3 +1,9 @@
1
+ import type {
2
+ BeginTopicStep,
3
+ TopicReceiptReferences,
4
+ } from '@ixo/ixo-transaction/topic-proposals';
5
+ import type { ProposalCommand } from '../action-proposals/store';
6
+ import type { ActionOutcomeV1 } from '@ixo/action-protocol';
1
7
  import type {
2
8
  TopicDeliverableCommand,
3
9
  TopicDeliverableResult,
@@ -14,9 +20,11 @@ import type {
14
20
  } from '@ixo/common/ai/decisions';
15
21
  import type { TranscriptPageOptions } from './transcript';
16
22
  import type { TurnUsage } from '../core/turn-budget';
23
+ import type { ToolSchedulerSnapshot } from '../core/tool-scheduler';
17
24
  import type { BotStatus, EncryptedFileInfo } from '@ixo/matrix-bot-workers-sdk';
18
25
  import type { AttachmentInput } from '../attachments/types';
19
26
  import type { RealtimeStatus } from '../realtime/realtime-endpoint';
27
+ import type { FrontendWaitingFor } from '../plugin-api/types';
20
28
  import type { TierFlushResult, TierStatus } from '../sqlite/do-vfs';
21
29
  import type { OwnedArtifact } from '../artifacts/store';
22
30
  import type {
@@ -48,6 +56,12 @@ import type { DomainContextProvenance } from '../core/domain-context';
48
56
  /** Bindings every oracle Worker must declare (see the example `wrangler.jsonc`). */
49
57
  export interface OracleWorkerEnv {
50
58
  TOPIC_DELIVERABLES_ENABLED?: string;
59
+ ACTION_PROPOSALS_RPC_TESTNET?: string;
60
+ ACTION_PROPOSALS_RPC_DEVNET?: string;
61
+ ACTION_PROPOSALS_RPC_MAINNET?: string;
62
+ ACTION_PROPOSALS_ENABLED?: string;
63
+ ACTION_TOPIC_PROPOSALS_ENABLED?: string;
64
+ ACTION_PROPOSALS_PORTAL_ORIGIN?: string;
51
65
  USER_ORACLE: DurableObjectNamespace<UserOracleObject>;
52
66
  MATRIX_GATEWAY: DurableObjectNamespace<MatrixGatewayObject>;
53
67
 
@@ -70,6 +84,8 @@ export interface OracleWorkerEnv {
70
84
  AUTH_HUB?: Fetcher;
71
85
  AUTH_HUB_URL?: string;
72
86
  AUTH_HUB_CHANNEL_SERVICE_KEY?: string;
87
+ HUB_ACTIONS_ENABLED?: string;
88
+ AUTH_HUB_ACTION_OUTCOME_KEY?: string;
73
89
 
74
90
  // --- matrix -------------------------------------------------------------
75
91
  MATRIX_BASE_URL: string;
@@ -251,6 +267,8 @@ export interface OracleWorkerEnv {
251
267
  TURN_MAX_IDENTICAL_READS?: string;
252
268
  /** Time limit per request-admission handler in ms (default 2000). */
253
269
  REQUEST_ADMISSION_TIMEOUT_MS?: string;
270
+ /** How long a frontend call waits unsent for a browser, in ms (default 300000). */
271
+ FRONTEND_PARK_TIMEOUT_MS?: string;
254
272
  /** Durable-run knobs (docs/plans/durable-runs.md); see `runDurabilityConfig`. */
255
273
  RUN_KEEPALIVE_MS?: string;
256
274
  RUN_SEGMENT_FLUSH_MS?: string;
@@ -367,6 +385,7 @@ export interface TurnRequest {
367
385
  /** A provider id (`/^[a-z][a-z0-9_-]{0,31}$/`), e.g. `whatsapp`. */
368
386
  provider: string;
369
387
  bindingId: string;
388
+ bindingRevision?: number;
370
389
  remoteMessageRef: string;
371
390
  };
372
391
  executionProfile?: TaskExecutionProfile;
@@ -405,7 +424,6 @@ export interface TurnRequest {
405
424
  taskRunId?: string;
406
425
  }
407
426
 
408
- /** A run as the shell reports it (`GET /sessions/:id/run`, `GET /debug/runs`). */
409
427
  /** `GET /debug/context` — the context budget a model gets on this object. */
410
428
  /**
411
429
  * Per-session context counters (`GET /debug/context?session=`): what the
@@ -500,6 +518,10 @@ export interface DomainContextRouterUpdate {
500
518
  domainContext: DomainContextProvenance[];
501
519
  }
502
520
 
521
+ /**
522
+ * A run as the shell reports it (`GET /sessions/:id/run`, `GET /runs/active`,
523
+ * `activeRun` on `GET /sessions` rows, `GET /debug/runs`).
524
+ */
503
525
  export interface RunSummary {
504
526
  runId: string;
505
527
  sessionId: string;
@@ -513,13 +535,27 @@ export interface RunSummary {
513
535
  | 'aborted'
514
536
  | 'interrupted'
515
537
  | 'failed';
538
+ /**
539
+ * When the run's row was created (`RunStore.create`), unchanged when a
540
+ * queued run starts: a dequeued run's elapsed time includes its wait.
541
+ */
516
542
  startedAt: string;
517
543
  updatedAt: string;
518
- /** Highest frame sequence a re-join can ask `after`. */
544
+ /**
545
+ * Highest frame sequence packed when the row was last written; written
546
+ * when the run ends, so not a live cursor for an active run.
547
+ */
519
548
  lastSeq: number;
520
549
  messageId?: string;
521
550
  /** The reply so far, for a run that ended without a committed reply. */
522
551
  partialText?: string;
552
+ /**
553
+ * The browser tool / AG-UI action the run is waiting on, parked because no
554
+ * browser of the session that can run it is connected: opening the chat
555
+ * sends it. Live state of this object only, never persisted; absent when
556
+ * the run waits on nothing.
557
+ */
558
+ waitingFor?: FrontendWaitingFor;
523
559
  }
524
560
 
525
561
  /** `GET /debug/runs`: the durable-run configuration, live runs and recent rows. */
@@ -573,6 +609,12 @@ export interface RunsStatus {
573
609
  startedAt: string;
574
610
  state: 'pending' | 'warned';
575
611
  }>;
612
+ /**
613
+ * Tool lanes of the object right now (tool-scheduler.ts): the user-wide
614
+ * `read` / `write` lanes, the per-session `frontend` and `subagent` lanes
615
+ * summed, and each busy per-session lane by key (`sessionLanes`).
616
+ */
617
+ tools: ToolSchedulerSnapshot;
576
618
  }
577
619
 
578
620
  /** Non-streaming turn result. */
@@ -611,6 +653,12 @@ export interface SessionSummary {
611
653
  createdAt: string;
612
654
  lastUpdatedAt: string;
613
655
  roomId?: string;
656
+ /**
657
+ * The run the session is busy with (queued / running / recovering), as
658
+ * `GET /runs/active` reports it: without `partialText`. Absent, never
659
+ * null, when the session has none.
660
+ */
661
+ activeRun?: RunSummary;
614
662
  }
615
663
 
616
664
  /** `search_memory_engine`'s raw upstream schema + whether it converts to Zod. */
@@ -688,6 +736,7 @@ export interface StorageStatus {
688
736
  * piped straight to the client.
689
737
  */
690
738
  export interface UserOracleObject extends Rpc.DurableObjectBranded {
739
+ recordHubActionOutcome(outcome: ActionOutcomeV1): Promise<void>;
691
740
  topicDeliverable(
692
741
  identity: TurnIdentity,
693
742
  operationId: string,
@@ -726,6 +775,18 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
726
775
  opts?: { limit?: number; offset?: number },
727
776
  ): Promise<{ sessions: SessionSummary[]; total: number }>;
728
777
  deleteSession(identity: TurnIdentity, sessionId: string): Promise<boolean>;
778
+ actionProposal(
779
+ identity: TurnIdentity,
780
+ id: string,
781
+ command?:
782
+ | ProposalCommand
783
+ | { kind: 'topic-reconcile'; input: TopicReceiptReferences },
784
+ ): Promise<JsonString | null>;
785
+ beginTopicStep(
786
+ identity: TurnIdentity,
787
+ id: string,
788
+ input: BeginTopicStep,
789
+ ): Promise<JsonString | null>;
729
790
  /** `GET /artifacts/:id` — the owner's canonical copy, or null. */
730
791
  artifact(
731
792
  identity: TurnIdentity,
@@ -761,12 +822,23 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
761
822
  target: FeedbackTarget,
762
823
  outcome: FeedbackSettlement,
763
824
  ): Promise<void>;
764
- abortTurn(sessionId: string): Promise<boolean>;
825
+ /**
826
+ * Stop the session's current message. With `identity` (the HTTP route)
827
+ * the object boots first and the answer waits until the stopped runs
828
+ * have ended (at most the supersede grace).
829
+ */
830
+ abortTurn(sessionId: string, identity?: TurnIdentity): Promise<boolean>;
765
831
  /** The session's active (queued/running/recovering) run, if any. */
766
832
  sessionRun(
767
833
  identity: TurnIdentity,
768
834
  sessionId: string,
769
835
  ): Promise<RunSummary | null>;
836
+ /**
837
+ * `GET /runs/active` — every active run of the chats `GET /sessions`
838
+ * lists (same main-room rule, task runs excluded), oldest first, without
839
+ * `partialText`.
840
+ */
841
+ activeRuns(identity: TurnIdentity): Promise<RunSummary[]>;
770
842
  /** Diagnostics: recent runs with their tool marks and live state. */
771
843
  runsStatus(): Promise<RunsStatus>;
772
844
  /** `GET /debug/context` — the context budget a model gets on this object. */
@@ -1022,6 +1094,17 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
1022
1094
  ): Promise<string>;
1023
1095
  /** Full room state as a JSON-encoded array of `{ type, state_key, content, sender, event_id }`. */
1024
1096
  getRoomState(roomId: string): Promise<JsonString>;
1097
+ getTopicRoomState(roomId: string): Promise<JsonString>;
1098
+ getTopicEvent(
1099
+ roomId: string,
1100
+ eventId: string,
1101
+ opts?: { deadlineAt?: number },
1102
+ ): Promise<JsonString | null>;
1103
+ getTopicHistory(
1104
+ roomId: string,
1105
+ root: string,
1106
+ opts?: { deadlineAt?: number; sender?: string },
1107
+ ): Promise<JsonString>;
1025
1108
  /** One timeline event (decrypted when E2EE) as JSON `{ event_id, type, content, sender, origin_server_ts }`, or null. */
1026
1109
  getEvent(roomId: string, eventId: string): Promise<JsonString | null>;
1027
1110
  /**
@@ -108,6 +108,8 @@ export interface RunCoordinatorHost {
108
108
  checkpointIdOf: (sessionId: string) => Promise<string | null>;
109
109
  /** After a run reached a terminal state and its row was closed. */
110
110
  onRunEnded?: (record: RunRecord, outcome: RunOutcome) => Promise<void>;
111
+ /** Before a run's row is created (the host marks that runs may be active). */
112
+ beforeCreate?: () => Promise<void>;
111
113
  /** How long a superseded or aborted attempt gets to wind down (default 5 s). */
112
114
  supersedeGraceMs?: number;
113
115
  }
@@ -215,6 +217,11 @@ export class RunCoordinator {
215
217
  return candidate;
216
218
  }
217
219
 
220
+ /** The record of every run this coordinator holds (not ended yet). */
221
+ records(): RunRecord[] {
222
+ return [...this.live.values()].map((run) => run.record);
223
+ }
224
+
218
225
  byRequestId(requestId: string): LiveRun | undefined {
219
226
  for (const run of this.live.values())
220
227
  if (run.requestId === requestId) return run;
@@ -367,6 +374,7 @@ export class RunCoordinator {
367
374
  }
368
375
  }
369
376
  const checkpointId = await this.host.checkpointIdOf(sessionId);
377
+ await this.host.beforeCreate?.();
370
378
  const record = await this.host.store.create({
371
379
  runId: input.runId,
372
380
  sessionId,
@@ -423,6 +431,28 @@ export class RunCoordinator {
423
431
  return true;
424
432
  }
425
433
 
434
+ /**
435
+ * `abortSession`, resolving once every run it stopped has ended — out of
436
+ * the live map, its row closed — or after the supersede grace, whichever
437
+ * comes first. Runs enqueued behind the active one are not stopped and
438
+ * not waited for.
439
+ */
440
+ async abortSessionSettled(sessionId: string): Promise<boolean> {
441
+ const before = [...this.live.values()].filter(
442
+ (run) => run.sessionId === sessionId,
443
+ );
444
+ const stopped = this.abortSession(sessionId);
445
+ const ending = before.filter(
446
+ (run) => this.live.get(run.runId) !== run || run.abort.signal.aborted,
447
+ );
448
+ if (ending.length > 0)
449
+ await withinGrace(
450
+ this.graceMs,
451
+ Promise.all(ending.map((run) => run.done)),
452
+ );
453
+ return stopped;
454
+ }
455
+
426
456
  /**
427
457
  * End every run of the session: the executing attempt is aborted, queued
428
458
  * runs and runs waiting for a recovery attempt are closed, and messages
@@ -78,7 +78,20 @@ export function clientSurfaceFor(
78
78
  };
79
79
  }
80
80
 
81
- export function runSummaryOf(record: RunRecord): RunSummary {
81
+ export interface RunSummaryOptions {
82
+ /**
83
+ * Carry the run's kept reply text (default true). The user-wide listings
84
+ * (`GET /runs/active`, `activeRun` on session rows) pass false: the text
85
+ * can be large and a client polling every chat does not need it; the
86
+ * session's own `GET /sessions/:id/run` still has it.
87
+ */
88
+ withPartialText?: boolean;
89
+ }
90
+
91
+ export function runSummaryOf(
92
+ record: RunRecord,
93
+ { withPartialText = true }: RunSummaryOptions = {},
94
+ ): RunSummary {
82
95
  return {
83
96
  runId: record.runId,
84
97
  sessionId: record.sessionId,
@@ -89,7 +102,7 @@ export function runSummaryOf(record: RunRecord): RunSummary {
89
102
  updatedAt: record.updatedAt,
90
103
  lastSeq: record.lastSeq,
91
104
  ...(record.messageId ? { messageId: record.messageId } : {}),
92
- ...(record.partialText && record.status !== 'finished'
105
+ ...(withPartialText && record.partialText && record.status !== 'finished'
93
106
  ? { partialText: record.partialText }
94
107
  : {}),
95
108
  };
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { DurableObject } from 'cloudflare:workers';
7
7
  import { DoSqliteDatabase } from '../sqlite/database';
8
+ import { activeChatRuns } from './active-runs';
8
9
  import type { RunSummary } from './contracts';
9
10
  import type { PackedSegment } from './run-buffer';
10
11
  import { RunCoordinator } from './run-coordinator';
@@ -172,6 +173,11 @@ export class RunStoreTestDO extends DurableObject {
172
173
  return (await this.runStore()).listActive();
173
174
  }
174
175
 
176
+ /** `activeChatRuns` over the given live records and this store's `listActive()`. */
177
+ async activeChatRuns(live: RunRecord[]): Promise<RunRecord[]> {
178
+ return activeChatRuns(live, await (await this.runStore()).listActive());
179
+ }
180
+
175
181
  async queuedForSession(sessionId: string): Promise<RunRecord[]> {
176
182
  return (await this.runStore()).queuedForSession(sessionId);
177
183
  }
@@ -202,6 +208,10 @@ export class RunStoreTestDO extends DurableObject {
202
208
  await (await this.runStore()).bumpMark(runId, toolCallId);
203
209
  }
204
210
 
211
+ async markSent(runId: string, toolCallId: string): Promise<void> {
212
+ await (await this.runStore()).markSent(runId, toolCallId);
213
+ }
214
+
205
215
  async finishMark(
206
216
  runId: string,
207
217
  toolCallId: string,
@@ -14,6 +14,8 @@
14
14
  * - `turn_tool_marks`: one row per tool call, written BEFORE the tool runs
15
15
  * (`started`) and updated when it returns (`done`). This is what makes a
16
16
  * resume safe: a started-but-unfinished write call is never re-executed.
17
+ * A frontend call also records when it reached a browser (`sent_at`):
18
+ * one still parked at the restart never left the object and runs again.
17
19
  * - `turn_write_claims`: one row per write whose outcome is not known yet
18
20
  * (tool-execution.ts), keyed by the fingerprint of the tool name and
19
21
  * arguments. Released by a returned outcome; kept by an abort, a deadline
@@ -22,7 +24,8 @@
22
24
  *
23
25
  * Cost shape (SQLite-backed DO storage bills rows written or deleted): a run
24
26
  * costs 1 insert + ~2 updates, a segment 1 insert + 1 delete, a tool call
25
- * 1 insert + 1 update, a write 1 insert + 1 delete more.
27
+ * 1 insert + 1 update (a frontend call 1 update more, when it is sent), a
28
+ * write 1 insert + 1 delete more.
26
29
  */
27
30
  import type { DoSqliteDatabase } from '../sqlite/database';
28
31
  import type { PackedSegment } from './run-buffer';
@@ -169,6 +172,14 @@ export interface ToolMark {
169
172
  outcome: string | null;
170
173
  /** Executions started for this call id (a read re-run bumps it). */
171
174
  attempts: number;
175
+ /**
176
+ * When the call was handed to its executor, for tools that run outside
177
+ * the object (frontend tools: the moment a browser socket received it).
178
+ * `null` while a frontend call is parked waiting for a socket, so a
179
+ * restart knows the call never left the object. Always `null` for tools
180
+ * that run in the object.
181
+ */
182
+ sentAt: string | null;
172
183
  }
173
184
 
174
185
  /** Rows of ended runs are kept a week (a re-join that late gets 404). */
@@ -331,6 +342,7 @@ type MarkRow = {
331
342
  done_at: string | null;
332
343
  outcome: string | null;
333
344
  attempts: number;
345
+ sent_at: string | null;
334
346
  } & Record<string, string | number | null>;
335
347
 
336
348
  type SegmentRow = {
@@ -393,6 +405,7 @@ function toMark(row: MarkRow): ToolMark {
393
405
  doneAt: row.done_at,
394
406
  outcome: row.outcome,
395
407
  attempts: Number(row.attempts),
408
+ sentAt: row.sent_at,
396
409
  };
397
410
  }
398
411
 
@@ -474,8 +487,21 @@ export class RunStore {
474
487
  done_at TEXT,
475
488
  outcome TEXT,
476
489
  attempts INTEGER NOT NULL DEFAULT 1,
490
+ sent_at TEXT,
477
491
  PRIMARY KEY (run_id, tool_call_id)
478
492
  ) WITHOUT ROWID`);
493
+ // When a call left the object (frontend calls; see `markSent`). Marks
494
+ // from before the column cannot tell, so they count as sent: a resume
495
+ // keeps treating them as it did when they were written.
496
+ const markColumns = await this.db.exec<{ name: string }>(
497
+ 'PRAGMA table_info(turn_tool_marks)',
498
+ );
499
+ if (!markColumns.some((c) => c.name === 'sent_at')) {
500
+ await this.db.run('ALTER TABLE turn_tool_marks ADD COLUMN sent_at TEXT');
501
+ await this.db.run(
502
+ 'UPDATE turn_tool_marks SET sent_at = started_at WHERE sent_at IS NULL',
503
+ );
504
+ }
479
505
  await this.db.run(`
480
506
  CREATE TABLE IF NOT EXISTS turn_write_claims (
481
507
  fingerprint TEXT PRIMARY KEY,
@@ -705,12 +731,12 @@ export class RunStore {
705
731
  return row ? toRecord(row) : undefined;
706
732
  }
707
733
 
708
- /** Every run that is not over, oldest first. */
734
+ /** Every run that is not over, oldest first (run id breaks a tie). */
709
735
  async listActive(): Promise<RunRecord[]> {
710
736
  await this.setup();
711
737
  const rows = await this.db.exec<RunRow>(
712
738
  `SELECT ${RUN_COLUMNS} FROM turn_runs
713
- WHERE status IN ('running','recovering','queued') ORDER BY started_at ASC`,
739
+ WHERE status IN ('running','recovering','queued') ORDER BY started_at ASC, run_id ASC`,
714
740
  );
715
741
  return rows.map(toRecord);
716
742
  }
@@ -853,16 +879,32 @@ export class RunStore {
853
879
  return undefined;
854
880
  }
855
881
 
856
- /** A read-only call re-executed on resume: count the execution, keep the row open. */
882
+ /**
883
+ * A call re-executed on resume: count the execution, keep the row open.
884
+ * The new execution has not been handed to an executor yet, so `sent_at`
885
+ * is cleared with it.
886
+ */
857
887
  async bumpMark(runId: string, toolCallId: string): Promise<void> {
858
888
  await this.setup();
859
889
  await this.db.run(
860
- `UPDATE turn_tool_marks SET attempts = attempts + 1, started_at = ?, done_at = NULL, outcome = NULL
890
+ `UPDATE turn_tool_marks SET attempts = attempts + 1, started_at = ?, done_at = NULL, outcome = NULL, sent_at = NULL
861
891
  WHERE run_id = ? AND tool_call_id = ?`,
862
892
  [this.iso(), runId, toolCallId],
863
893
  );
864
894
  }
865
895
 
896
+ /**
897
+ * The call left the object: its executor (a browser socket for a frontend
898
+ * tool) received it. From here on a restart cannot tell whether it ran.
899
+ */
900
+ async markSent(runId: string, toolCallId: string): Promise<void> {
901
+ await this.setup();
902
+ await this.db.run(
903
+ `UPDATE turn_tool_marks SET sent_at = ? WHERE run_id = ? AND tool_call_id = ?`,
904
+ [this.iso(), runId, toolCallId],
905
+ );
906
+ }
907
+
866
908
  async finishMark(
867
909
  runId: string,
868
910
  toolCallId: string,
@@ -881,7 +923,7 @@ export class RunStore {
881
923
  ): Promise<ToolMark | undefined> {
882
924
  await this.setup();
883
925
  const row = await this.db.get<MarkRow>(
884
- `SELECT run_id, tool_call_id, tool_name, effect, started_at, done_at, outcome, attempts
926
+ `SELECT run_id, tool_call_id, tool_name, effect, started_at, done_at, outcome, attempts, sent_at
885
927
  FROM turn_tool_marks WHERE run_id = ? AND tool_call_id = ?`,
886
928
  [runId, toolCallId],
887
929
  );
@@ -891,7 +933,7 @@ export class RunStore {
891
933
  async listMarks(runId: string): Promise<ToolMark[]> {
892
934
  await this.setup();
893
935
  const rows = await this.db.exec<MarkRow>(
894
- `SELECT run_id, tool_call_id, tool_name, effect, started_at, done_at, outcome, attempts
936
+ `SELECT run_id, tool_call_id, tool_name, effect, started_at, done_at, outcome, attempts, sent_at
895
937
  FROM turn_tool_marks WHERE run_id = ? ORDER BY started_at ASC`,
896
938
  [runId],
897
939
  );
@@ -902,10 +944,14 @@ export class RunStore {
902
944
 
903
945
  /**
904
946
  * Claim a write before it runs. `claimed` when no identical write is
905
- * outstanding. `blocked` when one is: a `pending` claim becomes `warned`
906
- * (owned by this run, so the same turn stays blocked); a claim already
907
- * `warned` by an earlier run is released to this run — the user was told
908
- * and asked again.
947
+ * outstanding. `blocked` when one is: a `pending` claim of this run, or of
948
+ * a run that is over, becomes `warned` (owned by this run, so the same
949
+ * turn stays blocked); a `pending` claim of ANOTHER run that is still
950
+ * active (queued / running / recovering) is left exactly as it is — its
951
+ * owner may still release it when its write returns, and a takeover would
952
+ * make that release delete nothing; a claim already `warned` by an
953
+ * earlier run is released to this run — the user was told and asked
954
+ * again.
909
955
  */
910
956
  async claimWrite(input: {
911
957
  fingerprint: string;
@@ -939,9 +985,16 @@ export class RunStore {
939
985
  return { status: 'claimed' };
940
986
  }
941
987
  if (existing.state === 'pending')
988
+ // One statement: the owner's liveness is read where the row is
989
+ // written, so no release or run end can slip in between.
942
990
  await this.db.run(
943
- `UPDATE turn_write_claims SET state = 'warned', run_id = ? WHERE fingerprint = ? AND state = 'pending'`,
944
- [input.runId, input.fingerprint],
991
+ `UPDATE turn_write_claims SET state = 'warned', run_id = ?
992
+ WHERE fingerprint = ? AND state = 'pending'
993
+ AND (run_id = ? OR NOT EXISTS (
994
+ SELECT 1 FROM turn_runs r WHERE r.run_id = turn_write_claims.run_id
995
+ AND r.status IN ('running','recovering','queued')
996
+ ))`,
997
+ [input.runId, input.fingerprint, input.runId],
945
998
  );
946
999
  return {
947
1000
  status: 'blocked',
@@ -5,6 +5,8 @@
5
5
  * missing `message` are all 400s, never a failed turn or a 500.
6
6
  */
7
7
 
8
+ import { declaredToolEffect } from '../plugins/portal/declared-tools';
9
+
8
10
  /** Top-level fields of Node's `SendMessageDto`; anything else is rejected. */
9
11
  export const TURN_BODY_KEYS: ReadonlySet<string> = new Set([
10
12
  'message',
@@ -45,6 +47,8 @@ export interface TurnBody {
45
47
  name: string;
46
48
  description: string;
47
49
  schema: Record<string, unknown>;
50
+ /** Declared effect; any value but `'read'` / `'write'` is dropped. */
51
+ effect?: 'read' | 'write';
48
52
  }>;
49
53
  agActions?: Array<{
50
54
  name: string;
@@ -87,6 +91,14 @@ function toolDeclaration(value: unknown): ToolDeclaration | null {
87
91
  };
88
92
  }
89
93
 
94
+ /** A browser tool declaration: a tool declaration plus its optional `effect`. */
95
+ function browserToolDeclaration(value: unknown): ToolDeclaration | null {
96
+ const declaration = toolDeclaration(value);
97
+ if (!declaration || !isRecord(value)) return declaration;
98
+ const effect = declaredToolEffect(value.effect);
99
+ return effect ? { ...declaration, effect } : declaration;
100
+ }
101
+
90
102
  /**
91
103
  * Parse and validate a raw request body. Never throws. Every field the
92
104
  * runtime reads is checked for its type here, so a wrong one is a 400 and
@@ -147,7 +159,7 @@ export function parseTurnBody(raw: string): ParsedTurnBody {
147
159
  if (!Array.isArray(record.tools)) return reject('tools must be an array');
148
160
  tools = [];
149
161
  for (const item of record.tools) {
150
- const tool = toolDeclaration(item);
162
+ const tool = browserToolDeclaration(item);
151
163
  if (!tool)
152
164
  return reject(
153
165
  'each tool needs a string name, description and an object schema',