@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/src/task-budget.ts
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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'))
|
|
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 (
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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
|
package/src/task-context.ts
CHANGED
|
@@ -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)
|
package/src/task-driver.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
473
|
-
|
|
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 = '
|
|
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';
|
package/src/task-policy.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/task-verification.ts
CHANGED
|
@@ -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
|
-
|
|
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',
|