@superjackfruitlabs/salaam 0.0.1 → 0.0.2

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 (110) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +25 -17
  3. package/dist/src/a2a-backend.js +225 -0
  4. package/dist/src/a2a-inbound.js +487 -0
  5. package/dist/src/a2a-peer.js +370 -0
  6. package/dist/src/a2a-protocol.js +250 -0
  7. package/dist/src/agent-profiles.js +289 -0
  8. package/dist/src/children.js +132 -26
  9. package/dist/src/claude-provider.js +2 -179
  10. package/dist/src/cli-help.js +107 -6
  11. package/dist/src/cli.js +477 -13
  12. package/dist/src/config.js +26 -3
  13. package/dist/src/credentials.js +7 -2
  14. package/dist/src/external-runtime.js +1 -0
  15. package/dist/src/external-work.js +583 -0
  16. package/dist/src/harness-backend.js +466 -0
  17. package/dist/src/harness-container.js +450 -0
  18. package/dist/src/harness-model-gateway.js +104 -0
  19. package/dist/src/harness-prepare.js +88 -0
  20. package/dist/src/harness-probe.js +111 -0
  21. package/dist/src/harness-protocol.js +325 -0
  22. package/dist/src/harness-readiness.js +50 -0
  23. package/dist/src/harness-rpc.js +206 -0
  24. package/dist/src/harness-tool-identity.js +87 -0
  25. package/dist/src/harness-wire-bridge.js +387 -0
  26. package/dist/src/host.js +6 -1
  27. package/dist/src/house-extension.js +147 -64
  28. package/dist/src/live-provider.js +301 -0
  29. package/dist/src/managed.js +33 -3
  30. package/dist/src/protocol.js +9 -5
  31. package/dist/src/provider-auth.js +300 -0
  32. package/dist/src/provider-config.js +65 -17
  33. package/dist/src/provider-model.js +78 -0
  34. package/dist/src/provider-payload.js +264 -0
  35. package/dist/src/provider-transport.js +36 -0
  36. package/dist/src/provider-usage.js +59 -0
  37. package/dist/src/repository-snapshots.js +219 -0
  38. package/dist/src/service.js +274 -9
  39. package/dist/src/store.js +44 -2
  40. package/dist/src/supervisor.js +246 -35
  41. package/dist/src/task-budget.js +17 -8
  42. package/dist/src/task-context.js +15 -0
  43. package/dist/src/task-driver.js +38 -14
  44. package/dist/src/task-policy.js +20 -1
  45. package/dist/src/task-verification.js +3 -0
  46. package/dist/src/task-work.js +31 -0
  47. package/dist/src/team-work.js +378 -0
  48. package/dist/src/tui-view.js +65 -10
  49. package/dist/src/tui.js +4 -2
  50. package/dist/src/usage.js +18 -0
  51. package/dist/src/worker-boundary.js +298 -0
  52. package/dist/src/worker-workspaces.js +274 -0
  53. package/dist/src/workspaces.js +6 -2
  54. package/harness-runtime/codex-pins.json +10 -0
  55. package/harness-runtime/package-lock.json +560 -0
  56. package/harness-runtime/package.json +9 -0
  57. package/npm-shrinkwrap.json +57 -2
  58. package/package.json +2 -1
  59. package/src/a2a-backend.ts +254 -0
  60. package/src/a2a-inbound.ts +585 -0
  61. package/src/a2a-peer.ts +441 -0
  62. package/src/a2a-protocol.ts +286 -0
  63. package/src/agent-profiles.ts +379 -0
  64. package/src/children.ts +168 -27
  65. package/src/claude-provider.ts +2 -189
  66. package/src/cli-help.ts +107 -6
  67. package/src/cli.ts +602 -12
  68. package/src/config.ts +34 -3
  69. package/src/credentials.ts +9 -2
  70. package/src/external-runtime.ts +54 -0
  71. package/src/external-work.ts +663 -0
  72. package/src/harness-backend.ts +557 -0
  73. package/src/harness-container.ts +543 -0
  74. package/src/harness-model-gateway.ts +138 -0
  75. package/src/harness-prepare.ts +113 -0
  76. package/src/harness-probe.ts +150 -0
  77. package/src/harness-protocol.ts +388 -0
  78. package/src/harness-readiness.ts +71 -0
  79. package/src/harness-rpc.ts +244 -0
  80. package/src/harness-tool-identity.ts +91 -0
  81. package/src/harness-wire-bridge.ts +418 -0
  82. package/src/host.ts +11 -6
  83. package/src/house-extension.ts +165 -66
  84. package/src/live-provider.ts +347 -0
  85. package/src/managed.ts +43 -4
  86. package/src/protocol.ts +36 -8
  87. package/src/provider-auth.ts +336 -0
  88. package/src/provider-config.ts +89 -18
  89. package/src/provider-model.ts +86 -0
  90. package/src/provider-payload.ts +263 -0
  91. package/src/provider-transport.ts +51 -0
  92. package/src/provider-usage.ts +64 -0
  93. package/src/repository-snapshots.ts +259 -0
  94. package/src/service.ts +446 -11
  95. package/src/store.ts +60 -4
  96. package/src/supervisor.ts +294 -41
  97. package/src/task-budget.ts +24 -11
  98. package/src/task-context.ts +17 -0
  99. package/src/task-driver.ts +51 -15
  100. package/src/task-policy.ts +23 -3
  101. package/src/task-verification.ts +2 -0
  102. package/src/task-work.ts +46 -1
  103. package/src/team-work.ts +554 -0
  104. package/src/tui-client.ts +5 -0
  105. package/src/tui-view.ts +164 -12
  106. package/src/tui.ts +5 -2
  107. package/src/usage.ts +17 -0
  108. package/src/worker-boundary.ts +395 -0
  109. package/src/worker-workspaces.ts +390 -0
  110. package/src/workspaces.ts +4 -2
@@ -39,19 +39,22 @@ export class TaskBudget {
39
39
  return child ? this.tasks.forSession(child.parent_session) : null;
40
40
  }
41
41
  state(id: string): Record<string, any> {
42
- return this.store.db.prepare('SELECT * FROM task_runtime WHERE task_id=?').get(id) as Record<
43
- string,
44
- any
45
- >;
42
+ const runtime = this.store.db.prepare('SELECT * FROM task_runtime WHERE task_id=?').get(id)!;
43
+ const n = Number(
44
+ this.store.db.prepare('SELECT count(*) AS n FROM task_invocations WHERE task_id=?').get(id)!
45
+ .n,
46
+ );
47
+ return { ...runtime, invocations_spent: n, external_model_usage: n ? 'unknown' : 'none' };
46
48
  }
47
49
  active(task: WorkTask): number {
48
50
  const row = this.store.db
49
51
  .prepare(
50
52
  `WITH tree(id) AS (SELECT ? UNION ALL SELECT child_session FROM child_tasks WHERE parent_session=?) SELECT
51
53
  (SELECT count(*) FROM attempts WHERE session_id IN (SELECT id FROM tree) AND state IN ('accepted','dispatching','running')) +
52
- (SELECT count(*) FROM command_runs WHERE session_id IN (SELECT id FROM tree) AND state NOT IN ('succeeded','failed','cancelled','timed_out','output_limited','interrupted')) AS n`,
54
+ (SELECT count(*) FROM command_runs WHERE session_id IN (SELECT id FROM tree) AND state NOT IN ('succeeded','failed','cancelled','timed_out','output_limited','interrupted')) +
55
+ (SELECT count(*) FROM external_runs e JOIN child_tasks c ON c.id=e.child_id JOIN attempts a ON a.id=c.child_attempt WHERE c.parent_session=? AND e.stopped=0 AND a.state NOT IN ('accepted','dispatching','running')) AS n`,
53
56
  )
54
- .get(task.session_id, task.session_id)!;
57
+ .get(task.session_id, task.session_id, task.session_id)!;
55
58
  return Number(row.n);
56
59
  }
57
60
  private deny(session: string, message: string): never {
@@ -84,8 +87,11 @@ export class TaskBudget {
84
87
  if (!attempt) this.deny(task.session_id, 'Task model work must belong to a durable attempt');
85
88
  const l = task.limits,
86
89
  r = this.state(task.id),
87
- charge = l.input_token_bound + l.output_token_bound;
88
- if (input > l.input_token_bound || output > l.output_token_bound)
90
+ charge = l.token_units === null ? 0 : l.input_token_bound + l.output_token_bound!;
91
+ if (
92
+ input > l.input_token_bound ||
93
+ (l.output_token_bound !== null && output > l.output_token_bound)
94
+ )
89
95
  this.deny(task.session_id, 'Provider payload exceeds declared per-request token bounds');
90
96
  const reservation = this.store.db
91
97
  .prepare('SELECT * FROM task_reservations WHERE attempt_id=? AND task_id=?')
@@ -107,7 +113,10 @@ export class TaskBudget {
107
113
  } else {
108
114
  if (Number(r.requests_spent) + Number(r.reserved_requests) >= l.requests)
109
115
  this.deny(task.session_id, 'Task request limit reached');
110
- if (Number(r.token_units) + Number(r.reserved_tokens) + charge > l.token_units)
116
+ if (
117
+ l.token_units !== null &&
118
+ Number(r.token_units) + Number(r.reserved_tokens) + charge > l.token_units
119
+ )
111
120
  this.deny(task.session_id, 'Task declared token budget reached');
112
121
  this.store.db
113
122
  .prepare(
@@ -121,10 +130,14 @@ export class TaskBudget {
121
130
  if (!task) return;
122
131
  this.check(session);
123
132
  const r = this.state(task.id),
124
- tokens = count * (task.limits.input_token_bound + task.limits.output_token_bound);
133
+ tokens =
134
+ task.limits.token_units === null
135
+ ? 0
136
+ : count * (task.limits.input_token_bound + task.limits.output_token_bound!);
125
137
  if (
126
138
  Number(r.requests_spent) + Number(r.reserved_requests) + count > task.limits.requests ||
127
- Number(r.token_units) + Number(r.reserved_tokens) + tokens > task.limits.token_units
139
+ (task.limits.token_units !== null &&
140
+ Number(r.token_units) + Number(r.reserved_tokens) + tokens > task.limits.token_units)
128
141
  )
129
142
  this.deny(task.session_id, 'Task cannot reserve the child request/token bounds');
130
143
  this.store.db
@@ -65,6 +65,22 @@ export class TaskContext {
65
65
  }
66
66
  frame(id: string, binding: string): string {
67
67
  const state = this.state(id);
68
+ const admitted = [
69
+ ...new Map(
70
+ (this.store.workerBoundary.profiles?.snapshot(id) ?? [])
71
+ .filter((p) => p.state === 'active')
72
+ .map((p) => [
73
+ p.profile_id,
74
+ {
75
+ profile_id: p.profile_id,
76
+ profile_digest: p.profile_digest,
77
+ tools: p.tools,
78
+ route: p.route,
79
+ max_depth: 1,
80
+ },
81
+ ]),
82
+ ).values(),
83
+ ].sort((a, b) => a.profile_id.localeCompare(b.profile_id));
68
84
  // The compaction cursor is a projection, not a task fact/version.
69
85
  const frame =
70
86
  'SALAAM_TASK_STATE ' +
@@ -75,6 +91,7 @@ export class TaskContext {
75
91
  instructions:
76
92
  'Use active facts. Superseded facts are historical. Do not repeat completed steps. Retrieve evidence with salaam_history.',
77
93
  facts: state.facts,
94
+ ...(admitted.length ? { admitted_agents: admitted } : {}),
78
95
  ...(this.work?.forSession(id) ? { work_task: this.work.frame(id) } : {}),
79
96
  });
80
97
  if (Buffer.byteLength(frame) > TASK_FRAME_BYTES)
@@ -42,6 +42,7 @@ export class TaskDriver {
42
42
  this.active = this.tick()
43
43
  .catch((error) => {
44
44
  // A persistence failure cannot authorize more work. Fence all affected task workers.
45
+ this.children.external?.failAll(error as Error);
45
46
  for (const row of this.store.db.prepare('SELECT session_id FROM durable_tasks').all())
46
47
  this.supervisor.failControl(String(row.session_id), error as Error);
47
48
  })
@@ -153,7 +154,7 @@ export class TaskDriver {
153
154
  private async stopActive(task: WorkTask): Promise<void> {
154
155
  const attempts = this.store.db
155
156
  .prepare(
156
- "SELECT id,state,submission_id FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running')",
157
+ "SELECT id,state,submission_id FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running','delegated')",
157
158
  )
158
159
  .all(task.session_id) as { id: string; state: string; submission_id: string }[];
159
160
  this.store.transaction(() => {
@@ -179,6 +180,7 @@ export class TaskDriver {
179
180
  await this.children.cancelOwned(attempt.id);
180
181
  await this.supervisor.cancelAttempt(attempt.id);
181
182
  }
183
+ await this.children.external?.cancelRoot(task.session_id);
182
184
  const compactions = this.store.db
183
185
  .prepare("SELECT id FROM compactions WHERE session_id=? AND state='running'")
184
186
  .all(task.session_id) as { id: string }[];
@@ -208,7 +210,12 @@ export class TaskDriver {
208
210
  return this.tasks.get(id);
209
211
  }
210
212
  /** Revocation is durable before any process abort, including a job cancellation. */
211
- cancelInTransaction(id: string, expected: number, authorize: () => void): { id: string } {
213
+ cancelInTransaction(
214
+ id: string,
215
+ expected: number,
216
+ authorize: () => void,
217
+ peer?: { peer: string; tenant: string },
218
+ ): { id: string } {
212
219
  if (!this.store.db.isTransaction) throw new Error('Task cancellation requires a transaction');
213
220
  authorize();
214
221
  const task = this.tasks.get(id);
@@ -217,12 +224,19 @@ export class TaskDriver {
217
224
  if (['succeeded', 'succeeded_with_exception', 'cancelled'].includes(task.state))
218
225
  throw new ServiceError('task_settled', 'Task already settled');
219
226
  this.store.db.prepare('UPDATE task_runtime SET auto_enabled=0 WHERE task_id=?').run(id);
227
+ this.children.cancelSessionInTransaction(task.session_id);
220
228
  this.store.db
221
- .prepare(
222
- "UPDATE durable_tasks SET state='cancelled',reason='Operator cancelled the task; inspect any uncertain effects',revision=revision+1 WHERE id=?",
223
- )
224
- .run(id);
225
- this.store.event(task.session_id, 'work_task.cancelled', { task_id: id });
229
+ .prepare("UPDATE durable_tasks SET state='cancelled',reason=?,revision=revision+1 WHERE id=?")
230
+ .run(
231
+ peer
232
+ ? 'Authenticated peer cancelled its dedicated task; inspect any uncertain effects'
233
+ : 'Operator cancelled the task; inspect any uncertain effects',
234
+ id,
235
+ );
236
+ this.store.event(task.session_id, 'work_task.cancelled', {
237
+ task_id: id,
238
+ ...(peer ? { peer } : {}),
239
+ });
226
240
  return { id };
227
241
  }
228
242
  async stopCancelled(id: string): Promise<void> {
@@ -361,17 +375,32 @@ export class TaskDriver {
361
375
  this.stop(task, 'An interrupted or failed step requires explicit operator review');
362
376
  return;
363
377
  }
364
- if (this.budget.active(task) > 0) {
365
- const accepted = task.steps.some((s) => s.state === 'queued');
378
+ const active = this.budget.active(task);
379
+ if (active > 0) {
380
+ const accepted = task.steps.some(
381
+ (s) => s.state === 'queued' && !this.tasks.delegatedStep?.(task.id, s.id),
382
+ );
366
383
  if (
367
384
  accepted &&
368
385
  !['ready', 'starting'].includes(this.supervisor.inspect(task.session_id).state)
369
386
  )
370
387
  await this.worker(task);
371
- return;
388
+ const rootActive = this.store.db
389
+ .prepare(
390
+ "SELECT 1 FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running')",
391
+ )
392
+ .get(task.session_id);
393
+ if (
394
+ rootActive ||
395
+ active >= task.limits.concurrency ||
396
+ this.children.activeCount(task.session_id) >= 2 ||
397
+ this.children.activeCount() >= 8
398
+ )
399
+ return;
372
400
  }
373
401
  const reviewing = task.steps.find((s) => s.state === 'awaiting_review');
374
- if (reviewing) {
402
+ if (reviewing && !active) {
403
+ this.tasks.assertStepReview?.(task.id, reviewing.id);
375
404
  if (!reviewing.checks?.length) {
376
405
  this.stop(task, 'Step requires operator review');
377
406
  return;
@@ -399,6 +428,7 @@ export class TaskDriver {
399
428
  return;
400
429
  }
401
430
  if (Number(r.remaining_steps) < 1) {
431
+ if (active) return;
402
432
  this.stop(task, 'Bounded progression step limit reached');
403
433
  return;
404
434
  }
@@ -406,14 +436,18 @@ export class TaskDriver {
406
436
  const next = task.steps.find(
407
437
  (s) =>
408
438
  s.state === 'pending' &&
439
+ (!active || this.tasks.delegatedStep?.(task.id, s.id)) &&
409
440
  s.depends_on.every((k) => task.steps.find((p) => p.key === k)?.state === 'completed'),
410
441
  );
411
442
  if (!next) {
443
+ if (active) return;
412
444
  this.stop(task, 'No eligible step; inspect the plan');
413
445
  return;
414
446
  }
415
- if (!(await this.worker(task)) || this.stopped) return;
447
+ const assigned = this.tasks.delegatedStep?.(task.id, next.id);
448
+ if ((!assigned && !(await this.worker(task))) || this.stopped) return;
416
449
  if (
450
+ !assigned &&
417
451
  r.auto_compact &&
418
452
  task.steps.some((s) => s.state === 'completed') &&
419
453
  !this.compact(task, next.id)
@@ -469,8 +503,10 @@ export class TaskDriver {
469
503
  reason = 'Explicit step attempt in progress';
470
504
  if (
471
505
  this.store.db
472
- .prepare("SELECT 1 FROM approvals WHERE session_id=? AND state='pending'")
473
- .get(task.session_id)
506
+ .prepare(
507
+ "SELECT 1 FROM approvals WHERE state='pending' AND (session_id=? OR session_id IN (SELECT child_session FROM child_tasks WHERE parent_session=?))",
508
+ )
509
+ .get(task.session_id, task.session_id)
474
510
  ) {
475
511
  state = 'waiting_for_person';
476
512
  reason = 'An operation-bound human approval is pending';
@@ -480,7 +516,7 @@ export class TaskDriver {
480
516
  .get(task.session_id)
481
517
  ) {
482
518
  state = 'waiting_for_child';
483
- reason = 'Read-only child work is pending';
519
+ reason = 'A child worker is pending';
484
520
  } else if (this.supervisor.inspect(task.session_id).state !== 'ready') {
485
521
  state = 'waiting_for_infrastructure';
486
522
  reason = 'Waiting for worker readiness';
@@ -7,12 +7,14 @@ export interface TaskCheck {
7
7
  cwd: string;
8
8
  }
9
9
  export interface TaskLimits {
10
+ /** Explicit opt-in for opaque runtimes. Omission never grants an invocation. */
11
+ invocations?: number;
10
12
  requests: number;
11
13
  runtime_ms: number;
12
14
  concurrency: number;
13
- token_units: number;
15
+ token_units: number | null;
14
16
  input_token_bound: number;
15
- output_token_bound: number;
17
+ output_token_bound: number | null;
16
18
  verification_runs: number;
17
19
  }
18
20
  export interface TaskPolicy {
@@ -63,10 +65,28 @@ export function taskPolicy(value: Record<string, unknown>): TaskPolicy {
63
65
  output_token_bound: 8192,
64
66
  verification_runs: 256,
65
67
  };
68
+ // Explicit opt-out; omitted limits retain the existing capped defaults.
69
+ if (input.token_units === null) {
70
+ limits.token_units = null;
71
+ if (input.output_token_bound === undefined) limits.output_token_bound = null;
72
+ }
66
73
  for (const key of Object.keys(limits) as (keyof TaskLimits)[]) {
74
+ if ((key === 'token_units' || key === 'output_token_bound') && input[key] === null) {
75
+ limits[key] = null;
76
+ continue;
77
+ }
67
78
  if (input[key] !== undefined) limits[key] = integer(input[key], key);
68
- if (limits[key] < 1 || limits[key] > max[key])
79
+ const bound = limits[key];
80
+ if (bound !== null && bound !== undefined && (bound < 1 || bound > max[key]!))
69
81
  throw new ServiceError('invalid_budget', 'Task limit out of range: ' + key, 400);
70
82
  }
83
+ if (limits.output_token_bound === null && limits.token_units !== null)
84
+ throw new ServiceError('invalid_budget', 'A token budget requires an output token bound', 400);
85
+ if (input.invocations !== undefined) {
86
+ const n = integer(input.invocations, 'invocations');
87
+ if (n < 1 || n > 64)
88
+ throw new ServiceError('invalid_budget', 'Invocation limit must be 1–64', 400);
89
+ limits.invocations = n;
90
+ }
71
91
  return { acceptance, deliverables, limits };
72
92
  }
@@ -125,6 +125,7 @@ export class TaskVerification {
125
125
  )
126
126
  throw new ServiceError('invalid_check', 'Step does not await this configured check');
127
127
  const w = this.workspaces.verify(task.session_id);
128
+ if (step) this.tasks.assertStepReview?.(task.id, step);
128
129
  if (!this.coding.executor)
129
130
  throw new ServiceError('verification_unavailable', 'No proven verification executor');
130
131
  this.budget.verification(task);
@@ -355,6 +356,7 @@ export class TaskVerification {
355
356
  if (step?.state === 'completed') return;
356
357
  if (step?.state !== 'awaiting_review' || !step.checks?.length)
357
358
  throw new ServiceError('verification_required', 'Step requires configured checks');
359
+ this.tasks.assertStepReview?.(current.id, stepId);
358
360
  this.assertIdle(current);
359
361
  const receipts = step.checks.map((key) => this.latest(current, key, stepId));
360
362
  if (receipts.some((r) => !r || !this.fresh(r)))
package/src/task-work.ts CHANGED
@@ -65,9 +65,13 @@ export interface WorkTask extends TaskPolicy {
65
65
 
66
66
  /** Operator-driven task ledger. An attempt outcome never constitutes task success. */
67
67
  export class TaskWork {
68
+ providerCheck?: (limits: TaskPolicy['limits']) => void;
68
69
  assertLimits?: (session: string) => void;
69
70
  assertProgressionBinding?: (task: WorkTask, binding: string) => void;
70
- private authorizeGrant(task: WorkTask, step: string, grant?: string): void {
71
+ delegatedStep?: (task: string, step: string) => boolean;
72
+ advanceDelegate?: (grant: string) => unknown;
73
+ assertStepReview?: (task: string, step: string) => void;
74
+ authorizeGrant(task: WorkTask, step: string, grant?: string): void {
71
75
  const row = this.store.db
72
76
  .prepare(
73
77
  'SELECT * FROM task_grants WHERE id=? AND task_id=? AND step_id=? AND plan_revision=?',
@@ -98,6 +102,7 @@ export class TaskWork {
98
102
  const grant = this.store.db.prepare('SELECT * FROM task_grants WHERE id=?').get(grantId) as
99
103
  { task_id: string; step_id: string; expected_revision: number } | undefined;
100
104
  if (!grant) throw new ServiceError('not_found', 'Task grant not found', 404);
105
+ if (this.delegatedStep?.(grant.task_id, grant.step_id)) return this.advanceDelegate!(grantId);
101
106
  return this.run(
102
107
  grant.task_id,
103
108
  grant.step_id,
@@ -317,6 +322,7 @@ export class TaskWork {
317
322
  if (!this.store.db.isTransaction) throw new Error('Task admission requires a transaction');
318
323
  const plan = this.parse(input);
319
324
  authorize();
325
+ this.providerCheck?.(plan.limits);
320
326
  if (expected !== 0)
321
327
  throw new ServiceError('task_revision_conflict', 'New tasks require revision zero');
322
328
  this.idle(session);
@@ -381,7 +387,32 @@ export class TaskWork {
381
387
  const task = this.get(id);
382
388
  this.store.authorizeControl(task.session_id, controller);
383
389
  this.expect(task, expected);
390
+ this.providerCheck?.(plan.limits);
384
391
  this.idle(task.session_id);
392
+ const runtime = this.store.db
393
+ .prepare('SELECT * FROM task_runtime WHERE task_id=?')
394
+ .get(id)!;
395
+ const invocations = Number(
396
+ this.store.db
397
+ .prepare('SELECT count(*) AS n FROM task_invocations WHERE task_id=?')
398
+ .get(id)!.n,
399
+ );
400
+ if (plan.limits.invocations !== undefined && plan.limits.invocations < invocations)
401
+ throw new ServiceError(
402
+ 'invalid_budget',
403
+ 'Invocation limits cannot discard admitted work',
404
+ );
405
+ if (
406
+ task.limits.token_units === null &&
407
+ plan.limits.token_units !== null &&
408
+ (Number(runtime.requests_spent) > 0 ||
409
+ Number(runtime.reserved_requests) > 0 ||
410
+ invocations > 0)
411
+ )
412
+ throw new ServiceError(
413
+ 'invalid_budget',
414
+ 'Cannot enable token budgeting after uncapped model work is admitted',
415
+ );
385
416
  for (const [index, step] of task.steps.entries()) {
386
417
  const next = plan.steps[index];
387
418
  if (!next || next.key !== step.key)
@@ -458,9 +489,15 @@ export class TaskWork {
458
489
  if (controller) this.store.authorizeControl(task.session_id, controller);
459
490
  else this.authorizeGrant(task, stepId, grantId);
460
491
  this.expect(task, expected);
492
+ this.providerCheck?.(task.limits);
461
493
  if (['cancelled', 'succeeded', 'succeeded_with_exception'].includes(task.state))
462
494
  throw new ServiceError('task_settled', 'Revise a settled task before another step');
463
495
  const step = task.steps.find((s) => s.id === stepId);
496
+ if (this.delegatedStep?.(id, stepId))
497
+ throw new ServiceError(
498
+ 'team_step_required',
499
+ 'Run assigned steps through their durable team assignment',
500
+ );
464
501
  if (!step) throw new ServiceError('invalid_step', 'Step does not belong to this task');
465
502
  if (!['pending', 'failed', 'interrupted', 'cancelled'].includes(step.state))
466
503
  throw new ServiceError('invalid_step_state', 'Step cannot be run in its current state');
@@ -552,6 +589,7 @@ export class TaskWork {
552
589
  'invalid_step_state',
553
590
  'Only a settled step awaiting operator review can be completed',
554
591
  );
592
+ this.assertStepReview?.(id, step.id);
555
593
  this.store.db
556
594
  .prepare("UPDATE task_steps SET state='completed',review=? WHERE id=?")
557
595
  .run(review, step.id);
@@ -588,9 +626,16 @@ export class TaskWork {
588
626
  .prepare('SELECT task_id,id,state FROM task_steps WHERE attempt_id=?')
589
627
  .get(attempt.id) as { task_id: string; id: string; state: WorkStep['state'] } | undefined;
590
628
  if (!row || row.state === 'completed') return;
629
+ if (
630
+ this.store.db
631
+ .prepare('SELECT 1 FROM team_rejections WHERE step_id=? AND attempt_id=?')
632
+ .get(row.id, attempt.id)
633
+ )
634
+ return;
591
635
  const state: WorkStep['state'] = (
592
636
  {
593
637
  accepted: 'queued',
638
+ delegated: 'queued',
594
639
  dispatching: 'running',
595
640
  running: 'running',
596
641
  completed: 'awaiting_review',