maka-agent 0.2.0-dev.22.20260905 → 0.2.0-dev.23.20260906

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 (63) hide show
  1. package/native/runtime-host-peer/prebuilds/darwin-arm64/maka_runtime_host_peer.node +0 -0
  2. package/native/runtime-host-peer/prebuilds/linux-arm64/maka_runtime_host_peer.node +0 -0
  3. package/native/runtime-host-peer/prebuilds/linux-x64/maka_runtime_host_peer.node +0 -0
  4. package/native/runtime-host-peer/prebuilds/win32-x64/maka_runtime_host_peer.node +0 -0
  5. package/native/runtime-host-windows-task-launcher/prebuilds/win32-x64/maka-runtime-host-task-launcher.exe +0 -0
  6. package/node_modules/@maka/core/dist/bot-events.js +18 -63
  7. package/node_modules/@maka/core/dist/model-call-attempt.js +0 -82
  8. package/node_modules/@maka/core/dist/model-call-usage-projection.js +0 -186
  9. package/node_modules/@maka/core/dist/record-schema.js +13 -0
  10. package/node_modules/@maka/core/dist/redaction.js +41 -58
  11. package/node_modules/@maka/core/dist/session-trace.js +5 -1
  12. package/node_modules/@maka/core/dist/usage-ledger-merge.js +7 -9
  13. package/node_modules/@maka/core/dist/workhub-creation-intent.js +34 -20
  14. package/node_modules/@maka/eval/harbor/egress-proxy/entrypoint.sh +2 -1
  15. package/node_modules/@maka/runtime/dist/ai-sdk-turn.js +51 -24
  16. package/node_modules/@maka/runtime/dist/bots/base-adapter.js +17 -0
  17. package/node_modules/@maka/runtime/dist/bots/bot-test.js +38 -22
  18. package/node_modules/@maka/runtime/dist/bots/dingtalk-bridge.js +7 -5
  19. package/node_modules/@maka/runtime/dist/bots/discord-bridge.js +4 -4
  20. package/node_modules/@maka/runtime/dist/bots/feishu-bridge.js +4 -5
  21. package/node_modules/@maka/runtime/dist/bots/qq-bridge.js +6 -4
  22. package/node_modules/@maka/runtime/dist/bots/slack-bridge.js +3 -4
  23. package/node_modules/@maka/runtime/dist/bots/telegram-bridge.js +4 -5
  24. package/node_modules/@maka/runtime/dist/bots/wechat-bridge.js +17 -19
  25. package/node_modules/@maka/runtime/dist/bots/wecom-bridge.js +5 -6
  26. package/node_modules/@maka/runtime/dist/bots/ws-bridge-base.js +2 -3
  27. package/node_modules/@maka/runtime/dist/session-trace-projection.js +3 -30
  28. package/node_modules/@maka/runtime-host/dist/candidate-entry.js +4 -1
  29. package/node_modules/@maka/runtime-host/dist/client/index.js +1 -1
  30. package/node_modules/@maka/runtime-host/dist/client/peer-client.js +13 -0
  31. package/node_modules/@maka/runtime-host/dist/client/registered-host-termination.js +0 -59
  32. package/node_modules/@maka/runtime-host/dist/peer-mesh/node.js +28 -16
  33. package/node_modules/@maka/runtime-host/dist/protocol/external-session.js +4 -0
  34. package/node_modules/@maka/runtime-host/dist/protocol/host-status.js +8 -0
  35. package/node_modules/@maka/runtime-host/dist/protocol/index.js +13 -1
  36. package/node_modules/@maka/runtime-host/dist/protocol/operations.js +1 -0
  37. package/node_modules/@maka/runtime-host/dist/protocol/session-collaboration.js +28 -1
  38. package/node_modules/@maka/runtime-host/dist/protocol/workhub-coordination.js +33 -0
  39. package/node_modules/@maka/runtime-host/dist/server/access-authority.js +26 -1
  40. package/node_modules/@maka/runtime-host/dist/server/access-credential-store.js +5 -1
  41. package/node_modules/@maka/runtime-host/dist/server/canonical-usage-reader.js +24 -11
  42. package/node_modules/@maka/runtime-host/dist/server/daily-review-coordinator.js +7 -4
  43. package/node_modules/@maka/runtime-host/dist/server/execution-composition.js +70 -6
  44. package/node_modules/@maka/runtime-host/dist/server/external-session-coordinator.js +6 -3
  45. package/node_modules/@maka/runtime-host/dist/server/host-kernel.js +12 -6
  46. package/node_modules/@maka/runtime-host/dist/server/message-coordinator.js +9 -2
  47. package/node_modules/@maka/runtime-host/dist/server/root-admission-owner.js +7 -0
  48. package/node_modules/@maka/runtime-host/dist/server/root-turn-coordinator.js +21 -0
  49. package/node_modules/@maka/runtime-host/dist/server/session-catalog-coordinator.js +151 -5
  50. package/node_modules/@maka/runtime-host/dist/server/usage-pricing-coordinator.js +4 -11
  51. package/node_modules/@maka/runtime-host/dist/server/workhub-coordination-action-gate.js +44 -15
  52. package/node_modules/@maka/runtime-host/dist/server/workhub-coordination-coordinator.js +6 -1
  53. package/node_modules/@maka/storage/dist/agent-run-store.js +44 -0
  54. package/node_modules/@maka/storage/dist/conversation-operational-state.js +4 -0
  55. package/node_modules/@maka/storage/dist/execution-stores.js +2 -0
  56. package/node_modules/@maka/storage/dist/model-call-ledger.js +199 -32
  57. package/node_modules/@maka/storage/dist/model-call-usage-sql.js +136 -0
  58. package/node_modules/@maka/storage/dist/operational-state-store-public.js +1 -1
  59. package/node_modules/@maka/storage/dist/operational-state-store.js +5 -3
  60. package/node_modules/@maka/storage/dist/sqlite-core-execution-schema.js +11 -1
  61. package/node_modules/@maka/storage/dist/sqlite-usage-schema.js +200 -20
  62. package/node_modules/@maka/storage/dist/usage-stores.js +6 -2
  63. package/package.json +1 -1
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { RuntimeHostProtocolError } from '../protocol/errors.js';
20
20
  import { createHash } from 'node:crypto';
21
- import { authorizeConnectionModel } from '@maka/core/llm-connections';
21
+ import { authorizeConnectionModel, connectionEnabledModelIds } from '@maka/core/llm-connections';
22
22
  import { isModelExplicitlyUnsupportedForChat } from '@maka/core/model-catalog';
23
23
  import { thinkingVariantsForConnection } from '@maka/core/model-thinking';
24
24
  import { executionBoundaryDisplayMode, } from '@maka/core/sandbox-boundary';
@@ -37,6 +37,84 @@ export class SessionOperationFailure extends Error {
37
37
  this.name = 'SessionOperationFailure';
38
38
  }
39
39
  }
40
+ /**
41
+ * The import path found no ready connection+model to attach the task to. A
42
+ * distinct type (not just a message) so `#importSession` can map it to the
43
+ * stable `model_unavailable` wire code without inspecting the message — the
44
+ * generic `operation_unavailable` code it carries is also used for an
45
+ * unavailable source, which is a different failure.
46
+ */
47
+ export class NoUsableImportModelError extends SessionOperationFailure {
48
+ constructor(message) {
49
+ super('operation_unavailable', message);
50
+ this.name = 'NoUsableImportModelError';
51
+ }
52
+ }
53
+ /**
54
+ * Connection+model candidates for an imported task, most-preferred first: the
55
+ * configured default (kept at its exact precedence), then one ready model per
56
+ * enabled connection in catalog order. Model-level readiness that is a pure
57
+ * catalog fact — enabled, not quarantined, chat-capable — is applied here so an
58
+ * unusable connection costs one `#resolveModel` attempt, not one per enabled
59
+ * model (a connection may enable hundreds). Connection-level readiness
60
+ * (credential, retired provider, identity) stays in `#resolveModel`, which
61
+ * remains the sole arbiter of those.
62
+ */
63
+ function importModelCandidates(snapshot) {
64
+ const byId = new Map(snapshot.connections.map((connection) => [connection.connectionId, connection]));
65
+ const candidates = [];
66
+ const seen = new Set();
67
+ const push = (connection, modelId, isDefault) => {
68
+ const key = `${connection.connectionId} ${modelId}`;
69
+ if (seen.has(key))
70
+ return;
71
+ seen.add(key);
72
+ candidates.push({
73
+ connectionId: connection.connectionId,
74
+ connectionSlug: connection.slug,
75
+ modelId,
76
+ isDefault,
77
+ });
78
+ };
79
+ // The connection's first enabled model that is a valid chat target, by pure
80
+ // catalog facts alone (no credential or provider-liveness read). Emitting only
81
+ // this one — rather than every enabled model — bounds the enumeration to one
82
+ // candidate per connection, so a connection-level failure (missing credential,
83
+ // retired provider) costs a single `#resolveModel` round trip.
84
+ const firstReadyModel = (connection) => {
85
+ for (const modelId of connectionEnabledModelIds(connection)) {
86
+ // Mirror `#resolveModel`'s pure-catalog gates, the wire byte cap included:
87
+ // a model id can be within the catalog's code-unit limit yet exceed the
88
+ // byte cap (e.g. emoji), and taking it as the connection's sole candidate
89
+ // would let `#resolveModel` reject it and mask the connection's shorter,
90
+ // usable models. Skip it here so the next enabled model is considered.
91
+ if (Buffer.byteLength(modelId, 'utf8') > SESSION_CATALOG_MODEL_MAX_BYTES)
92
+ continue;
93
+ const model = authorizeConnectionModel(connection, modelId);
94
+ if (model && !isModelExplicitlyUnsupportedForChat(model))
95
+ return modelId;
96
+ }
97
+ return undefined;
98
+ };
99
+ // Default first, so a configured-and-ready default keeps today's behavior.
100
+ // `retainedDefaultTarget` + `isValidTarget` guarantee a persisted default is
101
+ // enabled and present in `enabledModelIds`, so its exact model is taken at its
102
+ // precedence rather than re-picked from the connection.
103
+ const preferred = snapshot.defaultTarget;
104
+ if (preferred) {
105
+ const connection = byId.get(preferred.connectionId);
106
+ if (connection?.enabled)
107
+ push(connection, preferred.modelId, true);
108
+ }
109
+ for (const connection of snapshot.connections) {
110
+ if (!connection.enabled)
111
+ continue;
112
+ const modelId = firstReadyModel(connection);
113
+ if (modelId !== undefined)
114
+ push(connection, modelId, false);
115
+ }
116
+ return candidates;
117
+ }
40
118
  /** Host-owned Session catalog, creation, and configuration authority. */
41
119
  export class HostSessionCatalogCoordinator {
42
120
  handlers = {
@@ -69,11 +147,29 @@ export class HostSessionCatalogCoordinator {
69
147
  this.#requestDrain = options.requestDrain;
70
148
  this.#sessionAccessAuthority = options.sessionAccessAuthority;
71
149
  }
150
+ /**
151
+ * Target for a task imported from another agent's conversation. Import is an
152
+ * explicit, one-off user action on a specific conversation, so it prefers the
153
+ * configured default but falls back to any ready connection+model — see
154
+ * `#resolveImportModel`.
155
+ */
72
156
  async resolveExternalSessionImportTarget() {
73
- const [model, policy] = await Promise.all([
74
- this.#resolveModel({ kind: 'default' }, undefined),
75
- this.#readRuntimePolicy(),
76
- ]);
157
+ return this.#composeCreateTarget(this.#resolveImportModel());
158
+ }
159
+ /**
160
+ * Target for the autonomous WorkHub coordination create path (its
161
+ * `resolveCreateTarget`). Unlike import there is no user in the loop to pick a
162
+ * model, so this fails closed when no default is configured rather than binding
163
+ * a connection the user never chose. Import's fallback deliberately does not
164
+ * reach here; keeping the two resolvers apart is what confines the guess to an
165
+ * explicit user
166
+ * action.
167
+ */
168
+ async resolveDefaultCreateTarget() {
169
+ return this.#composeCreateTarget(this.#resolveModel({ kind: 'default' }, undefined));
170
+ }
171
+ async #composeCreateTarget(modelResolution) {
172
+ const [model, policy] = await Promise.all([modelResolution, this.#readRuntimePolicy()]);
77
173
  return {
78
174
  llmConnectionId: model.connectionId,
79
175
  llmConnectionSlug: model.connectionSlug,
@@ -511,6 +607,56 @@ export class HostSessionCatalogCoordinator {
511
607
  throw error;
512
608
  }
513
609
  }
610
+ /**
611
+ * Model for an imported task. Prefers the configured default but falls back to
612
+ * any ready connection+model, because a default is only auto-set during
613
+ * onboarding bootstrap (`setDefaultIfMissing`) and a self-configured profile
614
+ * legitimately has `defaultTarget: null` while holding perfectly usable
615
+ * connections. Without the fallback, every import fails before the source is
616
+ * even read. Unlike interactive session creation, import has no model picker,
617
+ * so this is the only place that can choose one.
618
+ */
619
+ async #resolveImportModel() {
620
+ let snapshot;
621
+ try {
622
+ snapshot = await this.#runtimePolicy.connectionCatalog.getSnapshot();
623
+ }
624
+ catch {
625
+ throw new SessionOperationFailure('persistence_failed', 'Connection catalog is unavailable');
626
+ }
627
+ for (const candidate of importModelCandidates(snapshot)) {
628
+ try {
629
+ return await this.#resolveModel({
630
+ kind: 'explicit',
631
+ connectionId: candidate.connectionId,
632
+ connectionSlug: candidate.connectionSlug,
633
+ model: candidate.modelId,
634
+ }, undefined);
635
+ }
636
+ catch (error) {
637
+ if (!(error instanceof SessionOperationFailure))
638
+ throw error;
639
+ // The configured default is never substituted away from: if it is set
640
+ // but unusable (e.g. its credential was revoked), surface its failure
641
+ // exactly as an explicit default target does today, rather than silently
642
+ // attaching the task to a connection the user did not choose. The
643
+ // fallback below runs only when no default is set — the null-default
644
+ // state this fix is for, where there is nothing to substitute for.
645
+ if (candidate.isDefault)
646
+ throw error;
647
+ // Otherwise, only a genuinely unusable candidate is skippable:
648
+ // `invalid_request` (disabled / retired / not-enabled / non-chat) and
649
+ // `operation_unavailable` (no credential). Any other code — notably
650
+ // `operation_conflict`, thrown when the connection was deleted or
651
+ // renamed after the snapshot — is a real fault the caller must see, not
652
+ // a reason to silently fall through to a lower-priority connection.
653
+ if (error.code !== 'invalid_request' && error.code !== 'operation_unavailable') {
654
+ throw error;
655
+ }
656
+ }
657
+ }
658
+ throw new NoUsableImportModelError('No usable Session model connection is available for import');
659
+ }
514
660
  async #resolveModel(target, thinkingLevel) {
515
661
  const selected = await this.#selectModelTarget(target);
516
662
  const readiness = await this.#runtimePolicy.operations.resolveExecutionConnection({
@@ -23,7 +23,7 @@ import { BUILTIN_PRICING } from '@maka/runtime/telemetry';
23
23
  import { authenticateInteractiveUsageStoresWriter, classifyInteractiveUsageStoresFailure, } from '@maka/storage/usage-stores';
24
24
  import { isSessionNotFoundError } from '@maka/storage/execution-stores';
25
25
  import { encodePricingQueryResult, encodeUsageQueryResult, PRICING_PAGE_MAX_BYTES, PRICING_PAGE_MAX_ITEMS, USAGE_PAGE_MAX_BYTES, USAGE_PAGE_MAX_ITEMS, USAGE_PROJECTION_TEXT_MAX_BYTES, } from '../protocol/index.js';
26
- import { readCanonicalUsage } from './canonical-usage-reader.js';
26
+ import { readCanonicalUsageBuckets, readCanonicalUsageLogs, readCanonicalUsageSummary, } from './canonical-usage-reader.js';
27
27
  /** Root-scoped projection over the authentic lease-bound usage stores. */
28
28
  export class HostUsagePricingCoordinator {
29
29
  handlers = {
@@ -75,18 +75,11 @@ export class HostUsagePricingCoordinator {
75
75
  }));
76
76
  return titles;
77
77
  }
78
- /**
79
- * Reads the canonical ledger for the window a query addresses (#1679). The
80
- * range is resolved once here so both sources answer the same window.
81
- */
82
- async #canonicalUsage(query, now, repair = true) {
83
- return readCanonicalUsage(this.#stores, query, now, repair);
84
- }
85
78
  async #queryUsage(input) {
86
79
  try {
87
80
  const now = Date.now();
88
81
  if (input.kind === 'summary') {
89
- const merged = mergeUsageSummary(await this.#stores.telemetry.summary(input.query), await this.#canonicalUsage(input.query, now), input.query, now);
82
+ const merged = mergeUsageSummary(await this.#stores.telemetry.summary(input.query), await readCanonicalUsageSummary(this.#stores, input.query, now));
90
83
  // Tool executions are in their own ledger, not the model-call one, so
91
84
  // their totals ride beside the merged summary rather than inside it —
92
85
  // the same owner split the tool buckets path already follows. A
@@ -117,7 +110,7 @@ export class HostUsagePricingCoordinator {
117
110
  ? { buckets: [...legacy], provenance: EMPTY_PROVENANCE }
118
111
  : mergeUsageBuckets(legacy,
119
112
  // Only the first page repairs; later pages reuse it.
120
- await this.#canonicalUsage(input.query, now, offset === 0), input.query, input.groupBy, now);
113
+ await readCanonicalUsageBuckets(this.#stores, input.query, input.groupBy, now, offset === 0));
121
114
  if (offset > merged.buckets.length)
122
115
  return invalidUsageOffset();
123
116
  return {
@@ -142,7 +135,7 @@ export class HostUsagePricingCoordinator {
142
135
  const legacy = await this.#stores.telemetry.logs(input.query, 0, offset + limit);
143
136
  const merged = mergeUsageLogs(legacy,
144
137
  // Only the first page repairs; later pages reuse it.
145
- await this.#canonicalUsage(input.query, now, offset === 0), input.query, now, offset, limit);
138
+ await readCanonicalUsageLogs(this.#stores, input.query, now, offset + limit, offset === 0), offset, limit);
146
139
  if (offset > merged.total)
147
140
  return invalidUsageOffset();
148
141
  const titles = await this.#resolveSessionTitles(merged.rows);
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { createHash } from 'node:crypto';
20
20
  import { WORKHUB_COORDINATION_SESSION_ID, isWorkHubCoordinationSessionTarget, } from '@maka/core/session';
21
- import { readWorkHubRequestIntent, workHubCorrectionTargetsSession, workHubCreationAuthorizesTitle, } from '@maka/core/workhub-creation-intent';
21
+ import { readWorkHubRequestIntent, workHubCorrectionTargetsSession, workHubCreationAuthorizesTitle, workHubNamedDelegationActionTargetsSession, } from '@maka/core/workhub-creation-intent';
22
22
  import { WORKHUB_COORDINATION_CANDIDATE_MAX_ITEMS } from '../protocol/index.js';
23
23
  const SIDE_CONVERSATION_LABEL = 'mode:side_conversation';
24
24
  const ACTION_REPLAY_MAX_ITEMS = 256;
@@ -74,9 +74,9 @@ export class WorkHubCoordinationActionGate {
74
74
  const action = { requestFingerprint, result };
75
75
  this.#actions.set(input.actionId, action);
76
76
  // Successful actions remain a Host-lifetime fast path. Rejections release
77
- // the slot so a pre-assignment admission can retry; the durable action
78
- // claim, not this map, is what owns the identity across that retry and
79
- // across restarts.
77
+ // the slot so admission can retry. Durable identity belongs to the
78
+ // owning operation: action claims for coordination writes, Host Turn
79
+ // admission for resume.
80
80
  void result.catch(() => {
81
81
  if (this.#actions.get(input.actionId) === action) {
82
82
  this.#actions.delete(input.actionId);
@@ -177,6 +177,26 @@ export class WorkHubCoordinationActionGate {
177
177
  });
178
178
  return this.#stop(requested, source);
179
179
  }
180
+ if (proposal.disposition === 'resume_work') {
181
+ if (!requestIntent.resume.imperative) {
182
+ throw new WorkHubActionGateFailure('action_conflict', 'WorkHub resume requires an explicit named command in trusted user text');
183
+ }
184
+ const candidates = await this.candidates();
185
+ const target = candidates.candidates.find((candidate) => candidate.sessionId === proposal.expects.targetSessionId);
186
+ if (!target)
187
+ throw new WorkHubActionGateFailure('candidate_unavailable', 'WorkHub resume target is unavailable');
188
+ this.#assertTarget(target);
189
+ const source = await this.#soleWorkingDelegation(target.sessionId, 'resume');
190
+ if (source.actionId !== proposal.resumesActionId) {
191
+ throw new WorkHubActionGateFailure('action_conflict', 'WorkHub resume target delegation changed');
192
+ }
193
+ const currentTargetName = target.sessionName;
194
+ if (!currentTargetName ||
195
+ !workHubNamedDelegationActionTargetsSession(requestIntent.resume, currentTargetName)) {
196
+ throw new WorkHubActionGateFailure('action_conflict', 'WorkHub resume target is not affirmed in trusted user text');
197
+ }
198
+ return this.#effects.resume({ actionId: input.actionId, source }, context);
199
+ }
180
200
  if (proposal.disposition === 'create_new') {
181
201
  if (!input.create || !workHubCreationAuthorizesTitle(requestIntent, proposal.title)) {
182
202
  throw new WorkHubActionGateFailure('action_conflict', 'WorkHub creation requires an unambiguous instruction and creation context');
@@ -259,9 +279,22 @@ export class WorkHubCoordinationActionGate {
259
279
  return claimed;
260
280
  }
261
281
  }
282
+ const resolved = await this.#soleWorkingDelegation(targetSessionId, 'stop');
283
+ // A claim with no request behind it resolves from the active links like a
284
+ // first attempt, but only while those links still name the delegation it
285
+ // bound itself to. If that one left and another took its place, the
286
+ // fingerprint derived here would no longer match the claim, and since
287
+ // claims are never deleted the refusal would be permanent and unexplained.
288
+ // Say why instead: the identity is spent, and the retry needs a new one.
289
+ if (claim?.operation === 'stop' && resolved.delegationId !== claim.subject) {
290
+ throw new WorkHubActionGateFailure('action_conflict', 'WorkHub stop identity is already bound to a different delegation');
291
+ }
292
+ return resolved;
293
+ }
294
+ async #soleWorkingDelegation(targetSessionId, operation) {
262
295
  const onTarget = await this.#effects.listActiveAssignments(targetSessionId);
263
296
  if (onTarget.length === 0) {
264
- throw new WorkHubActionGateFailure('action_conflict', 'WorkHub has no active durable delegation to stop on that Session');
297
+ throw new WorkHubActionGateFailure('action_conflict', `WorkHub has no active durable delegation to ${operation} on that Session`);
265
298
  }
266
299
  // One link is the answer whatever state its work is in. Whether that work
267
300
  // finished, or was never WorkHub's to stop, is what the stop resolves to —
@@ -281,19 +314,10 @@ export class WorkHubCoordinationActionGate {
281
314
  }
282
315
  }
283
316
  if (holdingWork.length !== 1) {
284
- throw new WorkHubActionGateFailure('action_conflict', 'WorkHub stop target does not identify one active durable delegation');
317
+ throw new WorkHubActionGateFailure('action_conflict', `WorkHub ${operation} target does not identify one active durable delegation`);
285
318
  }
286
319
  resolved = holdingWork[0];
287
320
  }
288
- // A claim with no request behind it resolves from the active links like a
289
- // first attempt, but only while those links still name the delegation it
290
- // bound itself to. If that one left and another took its place, the
291
- // fingerprint derived here would no longer match the claim, and since
292
- // claims are never deleted the refusal would be permanent and unexplained.
293
- // Say why instead: the identity is spent, and the retry needs a new one.
294
- if (claim?.operation === 'stop' && resolved.delegationId !== claim.subject) {
295
- throw new WorkHubActionGateFailure('action_conflict', 'WorkHub stop identity is already bound to a different delegation');
296
- }
297
321
  return resolved;
298
322
  }
299
323
  async #stop(request, source) {
@@ -608,6 +632,11 @@ function delegationAssignment(input, actionFingerprint, targetSessionId, targetS
608
632
  function workHubCreatedSessionId(actionId) {
609
633
  return `whs_${hash(`create\0${actionId}`).slice(0, 48)}`;
610
634
  }
635
+ // One request cannot resume a later interruption after a lost response and restart.
636
+ // Host admission rejects reuse of this Turn id for a different source boundary.
637
+ export function workHubResumedTurnId(actionId) {
638
+ return `wht_${hash(`resume\0${actionId}`).slice(0, 48)}`;
639
+ }
611
640
  function workspaceProjection(session) {
612
641
  return {
613
642
  target: typeof session.projectId === 'string'
@@ -110,6 +110,11 @@ export class HostWorkHubCoordinationCoordinator {
110
110
  resolveStop: (input) => this.#resolveStop(input),
111
111
  readDelegationRetirement: options.sessionActions.readDelegationRetirement,
112
112
  retireDelegation: options.sessionActions.retireDelegation,
113
+ resume: async (input, context) => ({
114
+ disposition: 'resume_work',
115
+ targetSessionId: input.source.targetSessionId,
116
+ ...(await options.sessionActions.resumeDelegation(input.source, context, input.actionId)),
117
+ }),
113
118
  });
114
119
  }
115
120
  #prepareReplacement(input) {
@@ -342,7 +347,7 @@ export class HostWorkHubCoordinationCoordinator {
342
347
  return {
343
348
  ok: false,
344
349
  error: {
345
- code: error.code === 'unauthorized' ? 'operation_unavailable' : error.code,
350
+ code: error.code,
346
351
  message: error.message,
347
352
  },
348
353
  };
@@ -258,6 +258,26 @@ class SqliteAgentRunStore {
258
258
  if (readSqliteRootTurnStartRejection(this.#lease.database, admission.sessionId, admission.turnId)) {
259
259
  throw new Error('Root Turn identity is already rejected');
260
260
  }
261
+ if (admission.execution.kind === 'safe_boundary_continuation') {
262
+ const sourceOwner = this.#lease.database
263
+ .prepare(`
264
+ SELECT turn_id
265
+ FROM core_root_turn_admissions
266
+ WHERE session_id = ?
267
+ AND json_extract(record_json, '$.execution.sourceTurnId') = ?
268
+ AND json_extract(record_json, '$.execution.sourceRunId') = ?
269
+ AND json_extract(record_json, '$.execution.kind') = 'safe_boundary_continuation'
270
+ ORDER BY admitted_at, turn_id
271
+ LIMIT 1
272
+ `)
273
+ .get(admission.sessionId, admission.execution.sourceTurnId, admission.execution.sourceRunId);
274
+ if (typeof sourceOwner?.turn_id === 'string') {
275
+ const owner = readSqliteRootTurnAdmission(this.#lease.database, admission.sessionId, sourceOwner.turn_id);
276
+ if (!owner)
277
+ throw new Error('Root continuation index has no durable admission');
278
+ return { kind: 'conflict', admission: owner };
279
+ }
280
+ }
261
281
  for (const source of admission.sourceMessages) {
262
282
  const proof = this.#lease.database
263
283
  .prepare(`
@@ -293,6 +313,30 @@ class SqliteAgentRunStore {
293
313
  assertSafeId(turnId, 'Invalid turn id');
294
314
  return readSqliteRootTurnAdmission(this.#lease.database, sessionId, turnId);
295
315
  }
316
+ async readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId) {
317
+ assertSafeId(sessionId, 'Invalid session id');
318
+ assertSafeId(sourceTurnId, 'Invalid source turn id');
319
+ assertSafeId(sourceRunId, 'Invalid source run id');
320
+ const row = this.#lease.database
321
+ .prepare(`
322
+ SELECT turn_id, record_json
323
+ FROM core_root_turn_admissions
324
+ WHERE session_id = ?
325
+ AND json_extract(record_json, '$.execution.sourceTurnId') = ?
326
+ AND json_extract(record_json, '$.execution.sourceRunId') = ?
327
+ AND json_extract(record_json, '$.execution.kind') = 'safe_boundary_continuation'
328
+ ORDER BY admitted_at, turn_id
329
+ LIMIT 1
330
+ `)
331
+ .get(sessionId, sourceTurnId, sourceRunId);
332
+ if (!row)
333
+ return undefined;
334
+ if (typeof row.turn_id !== 'string' || typeof row.record_json !== 'string') {
335
+ throw new Error('Invalid SQLite root turn continuation admission row');
336
+ }
337
+ const admission = normalizeRootTurnAdmission(JSON.parse(row.record_json), sessionId, row.turn_id);
338
+ return admission;
339
+ }
296
340
  async readRootTurnStartRejection(sessionId, turnId) {
297
341
  assertSafeId(sessionId, 'Invalid session id');
298
342
  assertSafeId(turnId, 'Invalid turn id');
@@ -67,6 +67,10 @@ class SqliteConversationOperationalStateStore {
67
67
  database
68
68
  .prepare('DELETE FROM core_root_turn_start_rejections WHERE session_id = ?')
69
69
  .run(sessionId);
70
+ // Cascades this run's events and the Usage projection's checkpoints.
71
+ // `usage_model_call_attempts` is deliberately absent from this list:
72
+ // deleting a conversation must not erase its spend from all-time Usage
73
+ // totals, so those rows outlive the authority they were projected from.
70
74
  database.prepare('DELETE FROM core_agent_runs WHERE session_id = ?').run(sessionId);
71
75
  database
72
76
  .prepare('DELETE FROM core_client_capability_session_grants WHERE session_id = ?')
@@ -210,6 +210,7 @@ async function createExecutionStoresForWrite(lease, kind, extension) {
210
210
  repairEventProjection: (sessionId, type, event, options) => run(() => agentRunStore.repairEventProjection(sessionId, type, event, options)),
211
211
  admitRootTurn: (input) => run(() => agentRunStore.admitRootTurn(input)),
212
212
  readRootTurnAdmission: (sessionId, turnId) => run(() => agentRunStore.readRootTurnAdmission(sessionId, turnId)),
213
+ readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => run(() => agentRunStore.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId)),
213
214
  readRootTurnStartRejection: (sessionId, turnId) => run(() => agentRunStore.readRootTurnStartRejection(sessionId, turnId)),
214
215
  commitRootTurnStartRejection: (input) => run(() => agentRunStore.commitRootTurnStartRejection(input)),
215
216
  readRootTurnSourceMessageReceipt: (sessionId, sourceMessageId) => run(() => agentRunStore.readRootTurnSourceMessageReceipt(sessionId, sourceMessageId)),
@@ -302,6 +303,7 @@ async function openExecutionStoresForRead(lease, kind, extension) {
302
303
  readEventsByTypeBounded: (sessionId, runId, type, budget) => run(() => agentRunStore.readEventsByTypeBounded(sessionId, runId, type, budget)),
303
304
  readEventProjection: (sessionId, type) => run(() => agentRunStore.readEventProjection(sessionId, type)),
304
305
  readRootTurnAdmission: (sessionId, turnId) => run(() => agentRunStore.readRootTurnAdmission(sessionId, turnId)),
306
+ readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => run(() => agentRunStore.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId)),
305
307
  readRootTurnSourceMessageReceipt: (sessionId, sourceMessageId) => run(() => agentRunStore.readRootTurnSourceMessageReceipt(sessionId, sourceMessageId)),
306
308
  },
307
309
  runtimeEventStore: {