@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.
- package/CHANGELOG.md +15 -0
- package/README.md +25 -17
- package/dist/src/a2a-backend.js +225 -0
- package/dist/src/a2a-inbound.js +487 -0
- package/dist/src/a2a-peer.js +370 -0
- package/dist/src/a2a-protocol.js +250 -0
- package/dist/src/agent-profiles.js +289 -0
- package/dist/src/children.js +132 -26
- package/dist/src/claude-provider.js +2 -179
- package/dist/src/cli-help.js +107 -6
- package/dist/src/cli.js +477 -13
- package/dist/src/config.js +26 -3
- package/dist/src/credentials.js +7 -2
- package/dist/src/external-runtime.js +1 -0
- package/dist/src/external-work.js +583 -0
- package/dist/src/harness-backend.js +466 -0
- package/dist/src/harness-container.js +450 -0
- package/dist/src/harness-model-gateway.js +104 -0
- package/dist/src/harness-prepare.js +88 -0
- package/dist/src/harness-probe.js +111 -0
- package/dist/src/harness-protocol.js +325 -0
- package/dist/src/harness-readiness.js +50 -0
- package/dist/src/harness-rpc.js +206 -0
- package/dist/src/harness-tool-identity.js +87 -0
- package/dist/src/harness-wire-bridge.js +387 -0
- package/dist/src/host.js +6 -1
- package/dist/src/house-extension.js +147 -64
- package/dist/src/live-provider.js +301 -0
- package/dist/src/managed.js +33 -3
- package/dist/src/protocol.js +9 -5
- package/dist/src/provider-auth.js +300 -0
- package/dist/src/provider-config.js +65 -17
- package/dist/src/provider-model.js +78 -0
- package/dist/src/provider-payload.js +264 -0
- package/dist/src/provider-transport.js +36 -0
- package/dist/src/provider-usage.js +59 -0
- package/dist/src/repository-snapshots.js +219 -0
- package/dist/src/service.js +274 -9
- package/dist/src/store.js +44 -2
- package/dist/src/supervisor.js +246 -35
- package/dist/src/task-budget.js +17 -8
- package/dist/src/task-context.js +15 -0
- package/dist/src/task-driver.js +38 -14
- package/dist/src/task-policy.js +20 -1
- package/dist/src/task-verification.js +3 -0
- package/dist/src/task-work.js +31 -0
- package/dist/src/team-work.js +378 -0
- package/dist/src/tui-view.js +65 -10
- package/dist/src/tui.js +4 -2
- package/dist/src/usage.js +18 -0
- package/dist/src/worker-boundary.js +298 -0
- package/dist/src/worker-workspaces.js +274 -0
- package/dist/src/workspaces.js +6 -2
- package/harness-runtime/codex-pins.json +10 -0
- package/harness-runtime/package-lock.json +560 -0
- package/harness-runtime/package.json +9 -0
- package/npm-shrinkwrap.json +57 -2
- package/package.json +2 -1
- package/src/a2a-backend.ts +254 -0
- package/src/a2a-inbound.ts +585 -0
- package/src/a2a-peer.ts +441 -0
- package/src/a2a-protocol.ts +286 -0
- package/src/agent-profiles.ts +379 -0
- package/src/children.ts +168 -27
- package/src/claude-provider.ts +2 -189
- package/src/cli-help.ts +107 -6
- package/src/cli.ts +602 -12
- package/src/config.ts +34 -3
- package/src/credentials.ts +9 -2
- package/src/external-runtime.ts +54 -0
- package/src/external-work.ts +663 -0
- package/src/harness-backend.ts +557 -0
- package/src/harness-container.ts +543 -0
- package/src/harness-model-gateway.ts +138 -0
- package/src/harness-prepare.ts +113 -0
- package/src/harness-probe.ts +150 -0
- package/src/harness-protocol.ts +388 -0
- package/src/harness-readiness.ts +71 -0
- package/src/harness-rpc.ts +244 -0
- package/src/harness-tool-identity.ts +91 -0
- package/src/harness-wire-bridge.ts +418 -0
- package/src/host.ts +11 -6
- package/src/house-extension.ts +165 -66
- package/src/live-provider.ts +347 -0
- package/src/managed.ts +43 -4
- package/src/protocol.ts +36 -8
- package/src/provider-auth.ts +336 -0
- package/src/provider-config.ts +89 -18
- package/src/provider-model.ts +86 -0
- package/src/provider-payload.ts +263 -0
- package/src/provider-transport.ts +51 -0
- package/src/provider-usage.ts +64 -0
- package/src/repository-snapshots.ts +259 -0
- package/src/service.ts +446 -11
- package/src/store.ts +60 -4
- package/src/supervisor.ts +294 -41
- package/src/task-budget.ts +24 -11
- package/src/task-context.ts +17 -0
- package/src/task-driver.ts +51 -15
- package/src/task-policy.ts +23 -3
- package/src/task-verification.ts +2 -0
- package/src/task-work.ts +46 -1
- package/src/team-work.ts +554 -0
- package/src/tui-client.ts +5 -0
- package/src/tui-view.ts +164 -12
- package/src/tui.ts +5 -2
- package/src/usage.ts +17 -0
- package/src/worker-boundary.ts +395 -0
- package/src/worker-workspaces.ts +390 -0
- package/src/workspaces.ts +4 -2
package/dist/src/task-driver.js
CHANGED
|
@@ -38,6 +38,7 @@ export class TaskDriver {
|
|
|
38
38
|
this.active = this.tick()
|
|
39
39
|
.catch((error) => {
|
|
40
40
|
// A persistence failure cannot authorize more work. Fence all affected task workers.
|
|
41
|
+
this.children.external?.failAll(error);
|
|
41
42
|
for (const row of this.store.db.prepare('SELECT session_id FROM durable_tasks').all())
|
|
42
43
|
this.supervisor.failControl(String(row.session_id), error);
|
|
43
44
|
})
|
|
@@ -119,7 +120,7 @@ export class TaskDriver {
|
|
|
119
120
|
}
|
|
120
121
|
async stopActive(task) {
|
|
121
122
|
const attempts = this.store.db
|
|
122
|
-
.prepare("SELECT id,state,submission_id FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running')")
|
|
123
|
+
.prepare("SELECT id,state,submission_id FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running','delegated')")
|
|
123
124
|
.all(task.session_id);
|
|
124
125
|
this.store.transaction(() => {
|
|
125
126
|
for (const attempt of attempts) {
|
|
@@ -144,6 +145,7 @@ export class TaskDriver {
|
|
|
144
145
|
await this.children.cancelOwned(attempt.id);
|
|
145
146
|
await this.supervisor.cancelAttempt(attempt.id);
|
|
146
147
|
}
|
|
148
|
+
await this.children.external?.cancelRoot(task.session_id);
|
|
147
149
|
const compactions = this.store.db
|
|
148
150
|
.prepare("SELECT id FROM compactions WHERE session_id=? AND state='running'")
|
|
149
151
|
.all(task.session_id);
|
|
@@ -164,7 +166,7 @@ export class TaskDriver {
|
|
|
164
166
|
return this.tasks.get(id);
|
|
165
167
|
}
|
|
166
168
|
/** Revocation is durable before any process abort, including a job cancellation. */
|
|
167
|
-
cancelInTransaction(id, expected, authorize) {
|
|
169
|
+
cancelInTransaction(id, expected, authorize, peer) {
|
|
168
170
|
if (!this.store.db.isTransaction)
|
|
169
171
|
throw new Error('Task cancellation requires a transaction');
|
|
170
172
|
authorize();
|
|
@@ -174,10 +176,16 @@ export class TaskDriver {
|
|
|
174
176
|
if (['succeeded', 'succeeded_with_exception', 'cancelled'].includes(task.state))
|
|
175
177
|
throw new ServiceError('task_settled', 'Task already settled');
|
|
176
178
|
this.store.db.prepare('UPDATE task_runtime SET auto_enabled=0 WHERE task_id=?').run(id);
|
|
179
|
+
this.children.cancelSessionInTransaction(task.session_id);
|
|
177
180
|
this.store.db
|
|
178
|
-
.prepare("UPDATE durable_tasks SET state='cancelled',reason
|
|
179
|
-
.run(
|
|
180
|
-
|
|
181
|
+
.prepare("UPDATE durable_tasks SET state='cancelled',reason=?,revision=revision+1 WHERE id=?")
|
|
182
|
+
.run(peer
|
|
183
|
+
? 'Authenticated peer cancelled its dedicated task; inspect any uncertain effects'
|
|
184
|
+
: 'Operator cancelled the task; inspect any uncertain effects', id);
|
|
185
|
+
this.store.event(task.session_id, 'work_task.cancelled', {
|
|
186
|
+
task_id: id,
|
|
187
|
+
...(peer ? { peer } : {}),
|
|
188
|
+
});
|
|
181
189
|
return { id };
|
|
182
190
|
}
|
|
183
191
|
async stopCancelled(id) {
|
|
@@ -288,15 +296,24 @@ export class TaskDriver {
|
|
|
288
296
|
this.stop(task, 'An interrupted or failed step requires explicit operator review');
|
|
289
297
|
return;
|
|
290
298
|
}
|
|
291
|
-
|
|
292
|
-
|
|
299
|
+
const active = this.budget.active(task);
|
|
300
|
+
if (active > 0) {
|
|
301
|
+
const accepted = task.steps.some((s) => s.state === 'queued' && !this.tasks.delegatedStep?.(task.id, s.id));
|
|
293
302
|
if (accepted &&
|
|
294
303
|
!['ready', 'starting'].includes(this.supervisor.inspect(task.session_id).state))
|
|
295
304
|
await this.worker(task);
|
|
296
|
-
|
|
305
|
+
const rootActive = this.store.db
|
|
306
|
+
.prepare("SELECT 1 FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running')")
|
|
307
|
+
.get(task.session_id);
|
|
308
|
+
if (rootActive ||
|
|
309
|
+
active >= task.limits.concurrency ||
|
|
310
|
+
this.children.activeCount(task.session_id) >= 2 ||
|
|
311
|
+
this.children.activeCount() >= 8)
|
|
312
|
+
return;
|
|
297
313
|
}
|
|
298
314
|
const reviewing = task.steps.find((s) => s.state === 'awaiting_review');
|
|
299
|
-
if (reviewing) {
|
|
315
|
+
if (reviewing && !active) {
|
|
316
|
+
this.tasks.assertStepReview?.(task.id, reviewing.id);
|
|
300
317
|
if (!reviewing.checks?.length) {
|
|
301
318
|
this.stop(task, 'Step requires operator review');
|
|
302
319
|
return;
|
|
@@ -322,19 +339,26 @@ export class TaskDriver {
|
|
|
322
339
|
return;
|
|
323
340
|
}
|
|
324
341
|
if (Number(r.remaining_steps) < 1) {
|
|
342
|
+
if (active)
|
|
343
|
+
return;
|
|
325
344
|
this.stop(task, 'Bounded progression step limit reached');
|
|
326
345
|
return;
|
|
327
346
|
}
|
|
328
347
|
this.tasks.assertProgressionBinding?.(task, String(r.auto_binding));
|
|
329
348
|
const next = task.steps.find((s) => s.state === 'pending' &&
|
|
349
|
+
(!active || this.tasks.delegatedStep?.(task.id, s.id)) &&
|
|
330
350
|
s.depends_on.every((k) => task.steps.find((p) => p.key === k)?.state === 'completed'));
|
|
331
351
|
if (!next) {
|
|
352
|
+
if (active)
|
|
353
|
+
return;
|
|
332
354
|
this.stop(task, 'No eligible step; inspect the plan');
|
|
333
355
|
return;
|
|
334
356
|
}
|
|
335
|
-
|
|
357
|
+
const assigned = this.tasks.delegatedStep?.(task.id, next.id);
|
|
358
|
+
if ((!assigned && !(await this.worker(task))) || this.stopped)
|
|
336
359
|
return;
|
|
337
|
-
if (
|
|
360
|
+
if (!assigned &&
|
|
361
|
+
r.auto_compact &&
|
|
338
362
|
task.steps.some((s) => s.state === 'completed') &&
|
|
339
363
|
!this.compact(task, next.id))
|
|
340
364
|
return;
|
|
@@ -377,8 +401,8 @@ export class TaskDriver {
|
|
|
377
401
|
if (task.steps.some((s) => ['queued', 'running'].includes(s.state)) && !r.blocked_reason) {
|
|
378
402
|
let state = 'running', reason = 'Explicit step attempt in progress';
|
|
379
403
|
if (this.store.db
|
|
380
|
-
.prepare("SELECT 1 FROM approvals WHERE
|
|
381
|
-
.get(task.session_id)) {
|
|
404
|
+
.prepare("SELECT 1 FROM approvals WHERE state='pending' AND (session_id=? OR session_id IN (SELECT child_session FROM child_tasks WHERE parent_session=?))")
|
|
405
|
+
.get(task.session_id, task.session_id)) {
|
|
382
406
|
state = 'waiting_for_person';
|
|
383
407
|
reason = 'An operation-bound human approval is pending';
|
|
384
408
|
}
|
|
@@ -386,7 +410,7 @@ export class TaskDriver {
|
|
|
386
410
|
.prepare("SELECT 1 FROM child_tasks WHERE parent_session=? AND state='accepted'")
|
|
387
411
|
.get(task.session_id)) {
|
|
388
412
|
state = 'waiting_for_child';
|
|
389
|
-
reason = '
|
|
413
|
+
reason = 'A child worker is pending';
|
|
390
414
|
}
|
|
391
415
|
else if (this.supervisor.inspect(task.session_id).state !== 'ready') {
|
|
392
416
|
state = 'waiting_for_infrastructure';
|
package/dist/src/task-policy.js
CHANGED
|
@@ -41,11 +41,30 @@ export function taskPolicy(value) {
|
|
|
41
41
|
output_token_bound: 8192,
|
|
42
42
|
verification_runs: 256,
|
|
43
43
|
};
|
|
44
|
+
// Explicit opt-out; omitted limits retain the existing capped defaults.
|
|
45
|
+
if (input.token_units === null) {
|
|
46
|
+
limits.token_units = null;
|
|
47
|
+
if (input.output_token_bound === undefined)
|
|
48
|
+
limits.output_token_bound = null;
|
|
49
|
+
}
|
|
44
50
|
for (const key of Object.keys(limits)) {
|
|
51
|
+
if ((key === 'token_units' || key === 'output_token_bound') && input[key] === null) {
|
|
52
|
+
limits[key] = null;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
45
55
|
if (input[key] !== undefined)
|
|
46
56
|
limits[key] = integer(input[key], key);
|
|
47
|
-
|
|
57
|
+
const bound = limits[key];
|
|
58
|
+
if (bound !== null && bound !== undefined && (bound < 1 || bound > max[key]))
|
|
48
59
|
throw new ServiceError('invalid_budget', 'Task limit out of range: ' + key, 400);
|
|
49
60
|
}
|
|
61
|
+
if (limits.output_token_bound === null && limits.token_units !== null)
|
|
62
|
+
throw new ServiceError('invalid_budget', 'A token budget requires an output token bound', 400);
|
|
63
|
+
if (input.invocations !== undefined) {
|
|
64
|
+
const n = integer(input.invocations, 'invocations');
|
|
65
|
+
if (n < 1 || n > 64)
|
|
66
|
+
throw new ServiceError('invalid_budget', 'Invocation limit must be 1–64', 400);
|
|
67
|
+
limits.invocations = n;
|
|
68
|
+
}
|
|
50
69
|
return { acceptance, deliverables, limits };
|
|
51
70
|
}
|
|
@@ -78,6 +78,8 @@ export class TaskVerification {
|
|
|
78
78
|
!task.steps.some((s) => s.id === step && s.state === 'awaiting_review' && s.checks?.includes(key)))
|
|
79
79
|
throw new ServiceError('invalid_check', 'Step does not await this configured check');
|
|
80
80
|
const w = this.workspaces.verify(task.session_id);
|
|
81
|
+
if (step)
|
|
82
|
+
this.tasks.assertStepReview?.(task.id, step);
|
|
81
83
|
if (!this.coding.executor)
|
|
82
84
|
throw new ServiceError('verification_unavailable', 'No proven verification executor');
|
|
83
85
|
this.budget.verification(task);
|
|
@@ -270,6 +272,7 @@ export class TaskVerification {
|
|
|
270
272
|
return;
|
|
271
273
|
if (step?.state !== 'awaiting_review' || !step.checks?.length)
|
|
272
274
|
throw new ServiceError('verification_required', 'Step requires configured checks');
|
|
275
|
+
this.tasks.assertStepReview?.(current.id, stepId);
|
|
273
276
|
this.assertIdle(current);
|
|
274
277
|
const receipts = step.checks.map((key) => this.latest(current, key, stepId));
|
|
275
278
|
if (receipts.some((r) => !r || !this.fresh(r)))
|
package/dist/src/task-work.js
CHANGED
|
@@ -5,8 +5,12 @@ import { canonical, object, ServiceError, string, } from './protocol.js';
|
|
|
5
5
|
export class TaskWork {
|
|
6
6
|
store;
|
|
7
7
|
context;
|
|
8
|
+
providerCheck;
|
|
8
9
|
assertLimits;
|
|
9
10
|
assertProgressionBinding;
|
|
11
|
+
delegatedStep;
|
|
12
|
+
advanceDelegate;
|
|
13
|
+
assertStepReview;
|
|
10
14
|
authorizeGrant(task, step, grant) {
|
|
11
15
|
const row = this.store.db
|
|
12
16
|
.prepare('SELECT * FROM task_grants WHERE id=? AND task_id=? AND step_id=? AND plan_revision=?')
|
|
@@ -34,6 +38,8 @@ export class TaskWork {
|
|
|
34
38
|
const grant = this.store.db.prepare('SELECT * FROM task_grants WHERE id=?').get(grantId);
|
|
35
39
|
if (!grant)
|
|
36
40
|
throw new ServiceError('not_found', 'Task grant not found', 404);
|
|
41
|
+
if (this.delegatedStep?.(grant.task_id, grant.step_id))
|
|
42
|
+
return this.advanceDelegate(grantId);
|
|
37
43
|
return this.run(grant.task_id, grant.step_id, 'task-grant:' + grantId, grant.expected_revision, null, null, null, grantId);
|
|
38
44
|
}
|
|
39
45
|
constructor(store, context) {
|
|
@@ -184,6 +190,7 @@ export class TaskWork {
|
|
|
184
190
|
throw new Error('Task admission requires a transaction');
|
|
185
191
|
const plan = this.parse(input);
|
|
186
192
|
authorize();
|
|
193
|
+
this.providerCheck?.(plan.limits);
|
|
187
194
|
if (expected !== 0)
|
|
188
195
|
throw new ServiceError('task_revision_conflict', 'New tasks require revision zero');
|
|
189
196
|
this.idle(session);
|
|
@@ -225,7 +232,22 @@ export class TaskWork {
|
|
|
225
232
|
const task = this.get(id);
|
|
226
233
|
this.store.authorizeControl(task.session_id, controller);
|
|
227
234
|
this.expect(task, expected);
|
|
235
|
+
this.providerCheck?.(plan.limits);
|
|
228
236
|
this.idle(task.session_id);
|
|
237
|
+
const runtime = this.store.db
|
|
238
|
+
.prepare('SELECT * FROM task_runtime WHERE task_id=?')
|
|
239
|
+
.get(id);
|
|
240
|
+
const invocations = Number(this.store.db
|
|
241
|
+
.prepare('SELECT count(*) AS n FROM task_invocations WHERE task_id=?')
|
|
242
|
+
.get(id).n);
|
|
243
|
+
if (plan.limits.invocations !== undefined && plan.limits.invocations < invocations)
|
|
244
|
+
throw new ServiceError('invalid_budget', 'Invocation limits cannot discard admitted work');
|
|
245
|
+
if (task.limits.token_units === null &&
|
|
246
|
+
plan.limits.token_units !== null &&
|
|
247
|
+
(Number(runtime.requests_spent) > 0 ||
|
|
248
|
+
Number(runtime.reserved_requests) > 0 ||
|
|
249
|
+
invocations > 0))
|
|
250
|
+
throw new ServiceError('invalid_budget', 'Cannot enable token budgeting after uncapped model work is admitted');
|
|
229
251
|
for (const [index, step] of task.steps.entries()) {
|
|
230
252
|
const next = plan.steps[index];
|
|
231
253
|
if (!next || next.key !== step.key)
|
|
@@ -277,9 +299,12 @@ export class TaskWork {
|
|
|
277
299
|
else
|
|
278
300
|
this.authorizeGrant(task, stepId, grantId);
|
|
279
301
|
this.expect(task, expected);
|
|
302
|
+
this.providerCheck?.(task.limits);
|
|
280
303
|
if (['cancelled', 'succeeded', 'succeeded_with_exception'].includes(task.state))
|
|
281
304
|
throw new ServiceError('task_settled', 'Revise a settled task before another step');
|
|
282
305
|
const step = task.steps.find((s) => s.id === stepId);
|
|
306
|
+
if (this.delegatedStep?.(id, stepId))
|
|
307
|
+
throw new ServiceError('team_step_required', 'Run assigned steps through their durable team assignment');
|
|
283
308
|
if (!step)
|
|
284
309
|
throw new ServiceError('invalid_step', 'Step does not belong to this task');
|
|
285
310
|
if (!['pending', 'failed', 'interrupted', 'cancelled'].includes(step.state))
|
|
@@ -327,6 +352,7 @@ export class TaskWork {
|
|
|
327
352
|
!step.attempt_id ||
|
|
328
353
|
this.store.attempt(step.attempt_id).state !== 'completed')
|
|
329
354
|
throw new ServiceError('invalid_step_state', 'Only a settled step awaiting operator review can be completed');
|
|
355
|
+
this.assertStepReview?.(id, step.id);
|
|
330
356
|
this.store.db
|
|
331
357
|
.prepare("UPDATE task_steps SET state='completed',review=? WHERE id=?")
|
|
332
358
|
.run(review, step.id);
|
|
@@ -354,8 +380,13 @@ export class TaskWork {
|
|
|
354
380
|
.get(attempt.id);
|
|
355
381
|
if (!row || row.state === 'completed')
|
|
356
382
|
return;
|
|
383
|
+
if (this.store.db
|
|
384
|
+
.prepare('SELECT 1 FROM team_rejections WHERE step_id=? AND attempt_id=?')
|
|
385
|
+
.get(row.id, attempt.id))
|
|
386
|
+
return;
|
|
357
387
|
const state = {
|
|
358
388
|
accepted: 'queued',
|
|
389
|
+
delegated: 'queued',
|
|
359
390
|
dispatching: 'running',
|
|
360
391
|
running: 'running',
|
|
361
392
|
completed: 'awaiting_review',
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { digest, ServiceError } from './protocol.js';
|
|
3
|
+
/** Assignment claims extend the task ledger. They are never an independent scheduler. */
|
|
4
|
+
export class TeamWork {
|
|
5
|
+
store;
|
|
6
|
+
tasks;
|
|
7
|
+
budget;
|
|
8
|
+
children;
|
|
9
|
+
profiles;
|
|
10
|
+
constructor(store, tasks, budget, children, profiles) {
|
|
11
|
+
this.store = store;
|
|
12
|
+
this.tasks = tasks;
|
|
13
|
+
this.budget = budget;
|
|
14
|
+
this.children = children;
|
|
15
|
+
this.profiles = profiles;
|
|
16
|
+
store.workerBoundary.assertAssignment = (binding) => this.assert(binding);
|
|
17
|
+
store.teamSnapshot = (session) => {
|
|
18
|
+
const task = tasks.forSession(session);
|
|
19
|
+
return task ? this.view(task.id) : null;
|
|
20
|
+
};
|
|
21
|
+
tasks.delegatedStep = (task, step) => !!this.forStep(task, step);
|
|
22
|
+
tasks.advanceDelegate = (id) => {
|
|
23
|
+
const grant = store.db.prepare('SELECT * FROM task_grants WHERE id=?').get(id);
|
|
24
|
+
const assignment = this.forStep(String(grant.task_id), String(grant.step_id));
|
|
25
|
+
if (!assignment)
|
|
26
|
+
throw new ServiceError('team_assignment_missing', 'Step assignment disappeared');
|
|
27
|
+
return this.run(assignment.id, 'task-grant:' + id, Number(grant.expected_revision), assignment.generation, null, null, null, id);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
forStep(task, step) {
|
|
31
|
+
return this.store.db
|
|
32
|
+
.prepare('SELECT * FROM team_assignments WHERE task_id=? AND step_id=? AND superseded=0')
|
|
33
|
+
.get(task, step);
|
|
34
|
+
}
|
|
35
|
+
get(id) {
|
|
36
|
+
const row = this.store.db
|
|
37
|
+
.prepare('SELECT * FROM team_assignments WHERE id=?')
|
|
38
|
+
.get(id);
|
|
39
|
+
if (!row)
|
|
40
|
+
throw new ServiceError('not_found', 'Team assignment not found', 404);
|
|
41
|
+
return row;
|
|
42
|
+
}
|
|
43
|
+
expect(task, revision) {
|
|
44
|
+
if (this.tasks.get(task).revision !== revision)
|
|
45
|
+
throw new ServiceError('task_revision_conflict', 'Task revision changed');
|
|
46
|
+
}
|
|
47
|
+
assign(taskId, stepId, profile, requests, request, expected, controller, runtime) {
|
|
48
|
+
return this.store.request('local-operator', request, {
|
|
49
|
+
kind: 'team-assign',
|
|
50
|
+
taskId,
|
|
51
|
+
stepId,
|
|
52
|
+
profile,
|
|
53
|
+
requests,
|
|
54
|
+
expected,
|
|
55
|
+
...(runtime ? { runtime } : {}),
|
|
56
|
+
}, () => {
|
|
57
|
+
const task = this.tasks.get(taskId);
|
|
58
|
+
this.store.authorizeControl(task.session_id, controller);
|
|
59
|
+
this.expect(taskId, expected);
|
|
60
|
+
if (['cancelled', 'succeeded', 'succeeded_with_exception'].includes(task.state))
|
|
61
|
+
throw new ServiceError('task_settled', 'Revise the task before assigning workers');
|
|
62
|
+
const step = task.steps.find((s) => s.id === stepId);
|
|
63
|
+
if (!step || step.attempt_id || step.state !== 'pending')
|
|
64
|
+
throw new ServiceError('invalid_step_state', 'Assign an unattempted pending step');
|
|
65
|
+
const old = this.forStep(taskId, stepId);
|
|
66
|
+
if (old) {
|
|
67
|
+
if (old.plan_revision === task.plan_revision || old.generation !== 0)
|
|
68
|
+
throw new ServiceError('team_assignment_exists', 'An immutable assignment already owns this step');
|
|
69
|
+
this.store.db.prepare('UPDATE team_assignments SET superseded=1 WHERE id=?').run(old.id);
|
|
70
|
+
}
|
|
71
|
+
const grant = this.profiles.resolve(task.session_id, profile);
|
|
72
|
+
if (!Number.isSafeInteger(requests) ||
|
|
73
|
+
requests < 1 ||
|
|
74
|
+
requests > (grant.profile.max_requests ?? 8))
|
|
75
|
+
throw new ServiceError('invalid_budget', 'Assignment exceeds profile request bounds');
|
|
76
|
+
const id = randomUUID();
|
|
77
|
+
this.store.db
|
|
78
|
+
.prepare('INSERT INTO team_assignments VALUES (?,?,?,?,?,?,?,0,NULL,0)')
|
|
79
|
+
.run(id, taskId, stepId, task.plan_revision, profile, grant.id, requests);
|
|
80
|
+
if (runtime) {
|
|
81
|
+
if (!this.children.external || requests !== 1)
|
|
82
|
+
throw new ServiceError('runtime_unavailable', 'External assignments require one invocation and a configured runtime');
|
|
83
|
+
this.children.external.assign(id, task, runtime, grant.profile.tools);
|
|
84
|
+
}
|
|
85
|
+
this.changed(taskId, 'assigned', {
|
|
86
|
+
assignment_id: id,
|
|
87
|
+
step_id: stepId,
|
|
88
|
+
profile_id: profile,
|
|
89
|
+
});
|
|
90
|
+
return this.get(id);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
run(id, request, expected, generation, instruction, continuation, controller, grantId, peerAdmission) {
|
|
94
|
+
let launch;
|
|
95
|
+
const result = this.store.request(peerAdmission?.scope ?? 'local-operator', request, {
|
|
96
|
+
kind: 'team-run',
|
|
97
|
+
id,
|
|
98
|
+
expected,
|
|
99
|
+
...(!grantId ? { generation } : {}),
|
|
100
|
+
instruction,
|
|
101
|
+
continuation,
|
|
102
|
+
...(grantId ? { grantId } : {}),
|
|
103
|
+
...(peerAdmission ? { peer_admission: peerAdmission.digest } : {}),
|
|
104
|
+
}, () => {
|
|
105
|
+
const row = this.get(id), task = this.tasks.get(row.task_id);
|
|
106
|
+
if (peerAdmission)
|
|
107
|
+
peerAdmission.authorize();
|
|
108
|
+
else if (controller)
|
|
109
|
+
this.store.authorizeControl(task.session_id, controller);
|
|
110
|
+
else
|
|
111
|
+
this.tasks.authorizeGrant(task, row.step_id, grantId);
|
|
112
|
+
this.expect(task.id, expected);
|
|
113
|
+
if (row.superseded ||
|
|
114
|
+
row.plan_revision !== task.plan_revision ||
|
|
115
|
+
row.generation !== generation)
|
|
116
|
+
throw new ServiceError('team_claim_stale', 'Assignment plan or generation changed');
|
|
117
|
+
if (generation >= 8)
|
|
118
|
+
throw new ServiceError('team_claim_limit', 'At most eight explicitly admitted generations per assignment');
|
|
119
|
+
if (['cancelled', 'succeeded', 'succeeded_with_exception'].includes(task.state))
|
|
120
|
+
throw new ServiceError('task_settled', 'Task no longer admits work');
|
|
121
|
+
const step = task.steps.find((s) => s.id === row.step_id);
|
|
122
|
+
if (!['pending', 'interrupted', 'failed', 'cancelled'].includes(step.state))
|
|
123
|
+
throw new ServiceError('invalid_step_state', 'Assignment is active or already awaiting review');
|
|
124
|
+
if (step.depends_on.some((key) => task.steps.find((s) => s.key === key)?.state !== 'completed'))
|
|
125
|
+
throw new ServiceError('dependency_pending', 'Complete predecessor steps first');
|
|
126
|
+
const interrupted = step.attempt_id && this.store.attempt(step.attempt_id).state === 'interrupted'
|
|
127
|
+
? step.attempt_id
|
|
128
|
+
: null;
|
|
129
|
+
if (continuation !== interrupted || (row.child_id && !instruction))
|
|
130
|
+
throw new ServiceError('invalid_continuation', 'Follow-up requires a new instruction and the exact interrupted attempt, if any');
|
|
131
|
+
if (instruction !== null && (!instruction.trim() || instruction.length > 4096))
|
|
132
|
+
throw new ServiceError('invalid_field', 'Supply a bounded follow-up instruction', 400);
|
|
133
|
+
if (row.child_id) {
|
|
134
|
+
const prior = this.children.inspect(task.session_id, row.child_id);
|
|
135
|
+
if (prior.state === 'accepted' ||
|
|
136
|
+
(prior.worker?.binding.external && !this.children.external?.stopped(prior.id)) ||
|
|
137
|
+
this.store.db
|
|
138
|
+
.prepare("SELECT 1 FROM workers WHERE session_id=? AND state!='exited'")
|
|
139
|
+
.get(prior.child_session))
|
|
140
|
+
throw new ServiceError('team_worker_uncertain', 'Confirm the previous process stopped before claiming a new generation');
|
|
141
|
+
}
|
|
142
|
+
const agent = this.profiles.resolve(task.session_id, row.profile_id);
|
|
143
|
+
if (agent.id !== row.grant_id)
|
|
144
|
+
throw new ServiceError('team_claim_stale', 'Assignment requires its original profile admission');
|
|
145
|
+
this.budget.check(task.session_id);
|
|
146
|
+
this.children.external?.validateAssignment(id, task);
|
|
147
|
+
if (this.budget.active(task) >= task.limits.concurrency)
|
|
148
|
+
throw new ServiceError('task_busy', 'Root task concurrency is full');
|
|
149
|
+
if (this.store.db
|
|
150
|
+
.prepare("SELECT 1 FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running','interrupted') AND resolved_by IS NULL")
|
|
151
|
+
.get(task.session_id))
|
|
152
|
+
throw new ServiceError('task_busy', 'Root session has pending work');
|
|
153
|
+
const envelope = randomUUID(), submission = randomUUID();
|
|
154
|
+
// A delegated envelope has never been dispatched to a model. Dispatcher excludes this state.
|
|
155
|
+
this.store.db
|
|
156
|
+
.prepare("INSERT INTO submissions(id,session_id,text,state,attempt_id) VALUES (?,?,?,'delegated',?)")
|
|
157
|
+
.run(submission, task.session_id, step.instruction, envelope);
|
|
158
|
+
this.store.db
|
|
159
|
+
.prepare("INSERT INTO attempts(id,session_id,submission_id,state) VALUES (?,?,?,'delegated')")
|
|
160
|
+
.run(envelope, task.session_id, submission);
|
|
161
|
+
const next = generation + 1;
|
|
162
|
+
const child = this.children.spawn(task.session_id, 0, envelope, `team:${id}:${next}`, instruction ?? step.instruction, row.budget, [], row.profile_id, {
|
|
163
|
+
authorize: () => { },
|
|
164
|
+
defer: true,
|
|
165
|
+
assignment: { id, generation: next },
|
|
166
|
+
runtime: this.children.external?.assignment(id),
|
|
167
|
+
});
|
|
168
|
+
this.store.db
|
|
169
|
+
.prepare('UPDATE team_assignments SET generation=?,child_id=? WHERE id=?')
|
|
170
|
+
.run(next, child.id, id);
|
|
171
|
+
this.store.db
|
|
172
|
+
.prepare('INSERT INTO task_step_attempts VALUES (?,?,?,?)')
|
|
173
|
+
.run(child.child_attempt, step.id, task.plan_revision, instruction ?? step.instruction);
|
|
174
|
+
this.store.db
|
|
175
|
+
.prepare("UPDATE task_steps SET attempt_id=?,state='queued',review=NULL WHERE id=?")
|
|
176
|
+
.run(child.child_attempt, step.id);
|
|
177
|
+
if (interrupted)
|
|
178
|
+
this.store.db
|
|
179
|
+
.prepare('UPDATE attempts SET resolved_by=? WHERE id=?')
|
|
180
|
+
.run(child.child_attempt, interrupted);
|
|
181
|
+
this.store.db
|
|
182
|
+
.prepare('UPDATE task_runtime SET started_at=coalesce(started_at,?) WHERE task_id=?')
|
|
183
|
+
.run(Date.now(), task.id);
|
|
184
|
+
this.store.db
|
|
185
|
+
.prepare("UPDATE durable_tasks SET state='running',reason='Team assignment claimed' WHERE id=?")
|
|
186
|
+
.run(task.id);
|
|
187
|
+
if (grantId)
|
|
188
|
+
this.store.db.prepare("UPDATE task_grants SET state='accepted' WHERE id=?").run(grantId);
|
|
189
|
+
this.changed(task.id, 'claimed', {
|
|
190
|
+
assignment_id: id,
|
|
191
|
+
generation: next,
|
|
192
|
+
child_id: child.id,
|
|
193
|
+
attempt_id: child.child_attempt,
|
|
194
|
+
});
|
|
195
|
+
peerAdmission?.committed(child);
|
|
196
|
+
launch = child;
|
|
197
|
+
return this.get(id);
|
|
198
|
+
});
|
|
199
|
+
if (launch)
|
|
200
|
+
this.children.launchPrepared(launch);
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
reject(id, request, expected, generation, reason, controller) {
|
|
204
|
+
return this.store.request('local-operator', request, { kind: 'team-reject', id, expected, generation, reason }, () => {
|
|
205
|
+
const assignment = this.get(id), task = this.tasks.get(assignment.task_id), step = task.steps.find((s) => s.id === assignment.step_id);
|
|
206
|
+
this.store.authorizeControl(task.session_id, controller);
|
|
207
|
+
this.expect(task.id, expected);
|
|
208
|
+
if (task.state === 'cancelled' ||
|
|
209
|
+
assignment.generation !== generation ||
|
|
210
|
+
!step.attempt_id ||
|
|
211
|
+
!['awaiting_review', 'failed', 'interrupted'].includes(step.state))
|
|
212
|
+
throw new ServiceError('team_claim_stale', 'Reject the current settled claim only');
|
|
213
|
+
if (!reason.trim() || reason.length > 4096)
|
|
214
|
+
throw new ServiceError('invalid_field', 'Record a bounded rejection reason', 400);
|
|
215
|
+
this.store.db
|
|
216
|
+
.prepare('INSERT INTO team_rejections VALUES (?,?,?,?)')
|
|
217
|
+
.run(step.id, step.attempt_id, request, reason);
|
|
218
|
+
this.store.db
|
|
219
|
+
.prepare("UPDATE task_steps SET state='failed',review=? WHERE id=?")
|
|
220
|
+
.run(reason, step.id);
|
|
221
|
+
this.store.db
|
|
222
|
+
.prepare('UPDATE task_runtime SET auto_enabled=0 WHERE task_id=?')
|
|
223
|
+
.run(task.id);
|
|
224
|
+
this.store.db
|
|
225
|
+
.prepare("UPDATE durable_tasks SET state='blocked',reason='Operator rejected worker proposal; explicit follow-up required' WHERE id=?")
|
|
226
|
+
.run(task.id);
|
|
227
|
+
this.changed(task.id, 'proposal_rejected', {
|
|
228
|
+
assignment_id: id,
|
|
229
|
+
generation,
|
|
230
|
+
attempt_id: step.attempt_id,
|
|
231
|
+
reason,
|
|
232
|
+
});
|
|
233
|
+
return this.get(id);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
assert(binding) {
|
|
237
|
+
const row = this.get(binding.assignment.id), task = this.tasks.get(row.task_id), step = task.steps.find((s) => s.id === row.step_id);
|
|
238
|
+
if (task.state === 'cancelled' ||
|
|
239
|
+
row.plan_revision !== task.plan_revision ||
|
|
240
|
+
row.generation !== binding.assignment.generation ||
|
|
241
|
+
row.child_id === null ||
|
|
242
|
+
this.children.inspect(task.session_id, row.child_id).child_attempt !== binding.attempt_id ||
|
|
243
|
+
step?.attempt_id !== binding.attempt_id ||
|
|
244
|
+
binding.owner_task !== task.id ||
|
|
245
|
+
binding.owner_session !== task.session_id ||
|
|
246
|
+
binding.agent?.id !== row.grant_id)
|
|
247
|
+
throw new ServiceError('team_claim_stale', 'Worker no longer owns the current task-step claim');
|
|
248
|
+
return row;
|
|
249
|
+
}
|
|
250
|
+
changed(task, kind, detail) {
|
|
251
|
+
this.store.db.prepare('UPDATE durable_tasks SET revision=revision+1 WHERE id=?').run(task);
|
|
252
|
+
this.store.event(this.tasks.get(task).session_id, 'team.' + kind, {
|
|
253
|
+
task_id: task,
|
|
254
|
+
...detail,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
view(task) {
|
|
258
|
+
const current = this.tasks.get(task);
|
|
259
|
+
const assignments = this.store.db
|
|
260
|
+
.prepare('SELECT * FROM team_assignments WHERE task_id=? ORDER BY rowid')
|
|
261
|
+
.all(task).map((a) => ({
|
|
262
|
+
...a,
|
|
263
|
+
stale: a.plan_revision !== current.plan_revision,
|
|
264
|
+
step_state: current.steps.find((s) => s.id === a.step_id)?.state,
|
|
265
|
+
worker: a.child_id ? this.children.inspect(current.session_id, a.child_id).worker : null,
|
|
266
|
+
runtime: this.children.external?.assignment(a.id) ?? null,
|
|
267
|
+
execution: a.child_id && this.children.inspect(current.session_id, a.child_id).worker?.binding.external
|
|
268
|
+
? {
|
|
269
|
+
state: this.children.external.row(a.child_id).state,
|
|
270
|
+
stopped: this.children.external.stopped(a.child_id),
|
|
271
|
+
stop_scope: this.children.external.assignment(a.id)?.kind === 'a2a'
|
|
272
|
+
? (() => {
|
|
273
|
+
const receipt = this.children.external.row(a.child_id).receipt;
|
|
274
|
+
return receipt
|
|
275
|
+
? (JSON.parse(receipt).scope ?? 'remote-terminal-receipt')
|
|
276
|
+
: 'remote-terminal-receipt';
|
|
277
|
+
})()
|
|
278
|
+
: 'owned-processes',
|
|
279
|
+
peer: (() => {
|
|
280
|
+
const row = this.children.external.row(a.child_id);
|
|
281
|
+
const cp = row.checkpoint ? JSON.parse(row.checkpoint) : null;
|
|
282
|
+
return cp?.dialect === 'a2a'
|
|
283
|
+
? {
|
|
284
|
+
peer_id: cp.peer_id,
|
|
285
|
+
task_id: cp.task_id,
|
|
286
|
+
context_id: cp.context_id,
|
|
287
|
+
cancel_state: cp.cancel_state ?? null,
|
|
288
|
+
remote_state: cp.result?.status?.state ?? null,
|
|
289
|
+
}
|
|
290
|
+
: null;
|
|
291
|
+
})(),
|
|
292
|
+
}
|
|
293
|
+
: null,
|
|
294
|
+
}));
|
|
295
|
+
const messages = this.store.db
|
|
296
|
+
.prepare('SELECT id,sender,recipient,created_at,delivered_at,acknowledged_at FROM team_messages WHERE task_id=? ORDER BY rowid DESC LIMIT 128')
|
|
297
|
+
.all(task);
|
|
298
|
+
return {
|
|
299
|
+
task_id: task,
|
|
300
|
+
assignments,
|
|
301
|
+
messages,
|
|
302
|
+
bounds: {
|
|
303
|
+
root_concurrency: current.limits.concurrency,
|
|
304
|
+
session_workers: 2,
|
|
305
|
+
daemon_workers: 8,
|
|
306
|
+
wake_on_message: false,
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
send(taskId, sender, recipient, text, messageId, authorize) {
|
|
311
|
+
return this.store.request(`team-message:${taskId}:${sender}`, messageId, { recipient, text }, () => {
|
|
312
|
+
authorize();
|
|
313
|
+
const task = this.tasks.get(taskId);
|
|
314
|
+
if (task.state === 'cancelled')
|
|
315
|
+
throw new ServiceError('task_settled', 'Task is cancelled');
|
|
316
|
+
if (this.get(recipient).task_id !== taskId ||
|
|
317
|
+
(sender !== 'operator' && this.get(sender).task_id !== taskId))
|
|
318
|
+
throw new ServiceError('team_message_scope', 'Messages stay within their owning task');
|
|
319
|
+
if (!text.trim() || Buffer.byteLength(text) > 4096 || !messageId || messageId.length > 128)
|
|
320
|
+
throw new ServiceError('invalid_field', 'Bound messages to 4 KiB and IDs to 128 characters', 400);
|
|
321
|
+
const n = Number(this.store.db
|
|
322
|
+
.prepare('SELECT count(*) AS n FROM team_messages WHERE task_id=?')
|
|
323
|
+
.get(taskId).n);
|
|
324
|
+
if (n >= 256 ||
|
|
325
|
+
Number(this.store.db
|
|
326
|
+
.prepare('SELECT count(*) AS n FROM team_messages WHERE recipient=? AND acknowledged_at IS NULL')
|
|
327
|
+
.get(recipient).n) >= 32)
|
|
328
|
+
throw new ServiceError('team_backpressure', 'Team message quota reached');
|
|
329
|
+
const id = randomUUID();
|
|
330
|
+
this.store.db
|
|
331
|
+
.prepare('INSERT INTO team_messages VALUES (?,?,?,?,?,?,?,NULL,NULL)')
|
|
332
|
+
.run(id, taskId, sender, recipient, text, digest({ sender, recipient, text }), Date.now());
|
|
333
|
+
this.store.event(task.session_id, 'team.message_saved', {
|
|
334
|
+
message_id: id,
|
|
335
|
+
sender,
|
|
336
|
+
recipient,
|
|
337
|
+
wakes_worker: false,
|
|
338
|
+
});
|
|
339
|
+
return { id, stored: true, delivered: false, acknowledged: false, wakes_worker: false };
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
receive(recipient, request, authorize) {
|
|
343
|
+
return this.store.request('team-receive:' + recipient, request, {}, () => {
|
|
344
|
+
authorize();
|
|
345
|
+
const row = this.get(recipient), now = Date.now();
|
|
346
|
+
const messages = this.store.db
|
|
347
|
+
.prepare('SELECT * FROM team_messages WHERE recipient=? AND acknowledged_at IS NULL ORDER BY rowid LIMIT 32')
|
|
348
|
+
.all(recipient);
|
|
349
|
+
for (const m of messages)
|
|
350
|
+
this.store.db
|
|
351
|
+
.prepare('UPDATE team_messages SET delivered_at=coalesce(delivered_at,?) WHERE id=?')
|
|
352
|
+
.run(now, String(m.id));
|
|
353
|
+
this.store.event(this.tasks.get(row.task_id).session_id, 'team.messages_delivered', {
|
|
354
|
+
recipient,
|
|
355
|
+
message_ids: messages.map((m) => m.id),
|
|
356
|
+
});
|
|
357
|
+
return messages.map((m) => ({ ...m, delivered_at: m.delivered_at ?? now }));
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
acknowledge(recipient, id, request, authorize) {
|
|
361
|
+
return this.store.request('team-ack:' + recipient, request, { id }, () => {
|
|
362
|
+
authorize();
|
|
363
|
+
const row = this.get(recipient), message = this.store.db
|
|
364
|
+
.prepare('SELECT * FROM team_messages WHERE id=? AND recipient=?')
|
|
365
|
+
.get(id, recipient);
|
|
366
|
+
if (!message?.delivered_at)
|
|
367
|
+
throw new ServiceError('message_not_delivered', 'Receive the message before acknowledging');
|
|
368
|
+
this.store.db
|
|
369
|
+
.prepare('UPDATE team_messages SET acknowledged_at=coalesce(acknowledged_at,?) WHERE id=?')
|
|
370
|
+
.run(Date.now(), id);
|
|
371
|
+
this.store.event(this.tasks.get(row.task_id).session_id, 'team.message_acknowledged', {
|
|
372
|
+
recipient,
|
|
373
|
+
message_id: id,
|
|
374
|
+
});
|
|
375
|
+
return { id, acknowledged: true };
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|