@superjackfruitlabs/salaam 0.0.3 → 0.0.5
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 +86 -0
- package/README.md +12 -11
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +182 -18
- package/dist/src/acp.js +296 -15
- package/dist/src/agent-profiles.js +45 -0
- package/dist/src/agentpod-authority.js +169 -11
- package/dist/src/agentpod-default-config.js +12 -0
- package/dist/src/agentpod-work-decision-protocol.js +40 -6
- package/dist/src/agentpod-work-decisions.js +108 -4
- package/dist/src/agentpod-work.js +1214 -127
- package/dist/src/approval-rules.js +287 -0
- package/dist/src/attach.js +3 -1
- package/dist/src/autonomy.js +80 -9
- package/dist/src/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +61 -6
- package/dist/src/cli.js +140 -4
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +327 -0
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host-bind.js +111 -0
- package/dist/src/host-breaker.js +88 -0
- package/dist/src/host-start.js +41 -1
- package/dist/src/host.js +284 -6
- package/dist/src/house-extension.js +292 -61
- package/dist/src/local-coding.js +726 -35
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-edits.js +168 -6
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/mentions.js +277 -0
- package/dist/src/operator-mcp-cli.js +107 -0
- package/dist/src/operator-mcp.js +342 -0
- package/dist/src/persona.js +31 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/pi.js +22 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +9 -3
- package/dist/src/rate-limit-retries.js +62 -0
- package/dist/src/recurring-work.js +1 -0
- package/dist/src/repository-snapshots.js +5 -2
- package/dist/src/run-grants.js +62 -0
- package/dist/src/service.js +482 -10
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/station-checkpoints.js +139 -0
- package/dist/src/station-config.js +8 -1
- package/dist/src/station-files.js +20 -1
- package/dist/src/station-task-verification.js +6 -3
- package/dist/src/station-work.js +315 -22
- package/dist/src/store.js +70 -4
- package/dist/src/supervisor.js +408 -25
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-context.js +8 -2
- package/dist/src/task-critic.js +115 -0
- package/dist/src/task-driver.js +46 -2
- package/dist/src/task-policy.js +44 -12
- package/dist/src/task-verification.js +42 -9
- package/dist/src/task-work.js +47 -0
- package/dist/src/terminal-status.js +88 -0
- package/dist/src/tool-identity.js +69 -0
- package/dist/src/tui-activity.js +99 -0
- package/dist/src/tui-classifier.js +25 -0
- package/dist/src/tui-client.js +186 -5
- package/dist/src/tui-commands.js +23 -5
- package/dist/src/tui-composer.js +113 -0
- package/dist/src/tui-keymap.js +116 -0
- package/dist/src/tui-mentions.js +38 -0
- package/dist/src/tui-modals.js +157 -34
- package/dist/src/tui-notify.js +62 -0
- package/dist/src/tui-review.js +55 -0
- package/dist/src/tui-turn-keys.js +52 -0
- package/dist/src/tui-view.js +243 -69
- package/dist/src/tui.js +875 -118
- package/dist/src/turn-checkpoints.js +221 -0
- package/dist/src/turn-rewind.js +168 -0
- package/dist/src/usage.js +1 -1
- package/dist/src/web-fetch.js +114 -0
- package/dist/src/web-guard.js +163 -0
- package/dist/src/web-search.js +302 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-desk.ts +155 -0
- package/src/acp-outcome.ts +174 -20
- package/src/acp.ts +386 -30
- package/src/agent-profiles.ts +48 -0
- package/src/agentpod-authority.ts +234 -15
- package/src/agentpod-default-config.ts +16 -0
- package/src/agentpod-work-decision-protocol.ts +69 -8
- package/src/agentpod-work-decisions.ts +123 -5
- package/src/agentpod-work.ts +1455 -152
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +60 -6
- package/src/cli.ts +174 -4
- package/src/command-danger.ts +886 -0
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +393 -0
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/execution-evidence.ts +31 -1
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host-bind.ts +156 -0
- package/src/host-breaker.ts +89 -0
- package/src/host-start.ts +51 -1
- package/src/host.ts +365 -6
- package/src/house-extension.ts +313 -60
- package/src/local-coding.ts +894 -43
- package/src/local-edit-helper.ts +26 -0
- package/src/local-edits.ts +191 -8
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/mentions.ts +304 -0
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi-sessions.ts +9 -1
- package/src/pi.ts +23 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +25 -3
- package/src/rate-limit-retries.ts +69 -0
- package/src/recurring-work.ts +1 -0
- package/src/repository-snapshots.ts +5 -2
- package/src/run-grants.ts +107 -0
- package/src/service.ts +709 -10
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/station-checkpoints.ts +183 -0
- package/src/station-config.ts +10 -1
- package/src/station-files.ts +20 -1
- package/src/station-task-verification.ts +6 -3
- package/src/station-work.ts +369 -20
- package/src/store.ts +70 -3
- package/src/supervisor.ts +463 -26
- package/src/task-budget.ts +48 -3
- package/src/task-context.ts +9 -2
- package/src/task-critic.ts +171 -0
- package/src/task-driver.ts +49 -2
- package/src/task-policy.ts +65 -13
- package/src/task-verification.ts +53 -9
- package/src/task-work.ts +75 -0
- package/src/terminal-status.ts +103 -0
- package/src/tool-identity.ts +110 -0
- package/src/tui-activity.ts +133 -0
- package/src/tui-classifier.ts +38 -0
- package/src/tui-client.ts +239 -8
- package/src/tui-commands.ts +49 -10
- package/src/tui-composer.ts +124 -0
- package/src/tui-keymap.ts +152 -0
- package/src/tui-mentions.ts +43 -0
- package/src/tui-modals.ts +173 -43
- package/src/tui-notify.ts +77 -0
- package/src/tui-review.ts +76 -0
- package/src/tui-turn-keys.ts +77 -0
- package/src/tui-view.ts +341 -88
- package/src/tui.ts +1047 -137
- package/src/turn-checkpoints.ts +253 -0
- package/src/turn-rewind.ts +258 -0
- package/src/usage.ts +1 -1
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
package/src/task-budget.ts
CHANGED
|
@@ -11,6 +11,13 @@ export class TaskBudgetError extends ServiceError {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** Tools a critic review child may hold and still be left out of the verification busy check. */
|
|
15
|
+
export const READ_ONLY_REVIEW_TOOLS = [
|
|
16
|
+
'salaam_findings',
|
|
17
|
+
'salaam_probe',
|
|
18
|
+
'salaam_repository',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
14
21
|
export class TaskBudget {
|
|
15
22
|
constructor(
|
|
16
23
|
readonly store: Store,
|
|
@@ -29,6 +36,8 @@ export class TaskBudget {
|
|
|
29
36
|
store.taskChildFinished = (attempt) => this.releaseChild(attempt);
|
|
30
37
|
store.taskCommandAdmission = (session) => this.command(session);
|
|
31
38
|
store.taskRemainingRuntime = (session) => this.remaining(session);
|
|
39
|
+
// Only the task's own session: a child's attempt keeps the budget its reservation set.
|
|
40
|
+
store.taskAttemptRequestLimit = (session) => this.tasks.forSession(session)?.limits.requests;
|
|
32
41
|
}
|
|
33
42
|
task(session: string): WorkTask | null {
|
|
34
43
|
const direct = this.tasks.forSession(session);
|
|
@@ -46,16 +55,52 @@ export class TaskBudget {
|
|
|
46
55
|
);
|
|
47
56
|
return { ...runtime, invocations_spent: n, external_model_usage: n ? 'unknown' : 'none' };
|
|
48
57
|
}
|
|
49
|
-
|
|
58
|
+
/** The child attempt of each running critic review of this task revision whose recorded
|
|
59
|
+
* tools are all read-only (operator ruling 2026-10-30, option A). Only verification reads may
|
|
60
|
+
* leave these out of the busy count: the reviewer reads a digest-bound snapshot and cannot
|
|
61
|
+
* write the workspace, run a command or reach the station (staging card_019a647a169744ff). */
|
|
62
|
+
private readOnlyReviews(task: WorkTask): string[] {
|
|
63
|
+
if (
|
|
64
|
+
!this.store.db
|
|
65
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_critics'")
|
|
66
|
+
.get()
|
|
67
|
+
)
|
|
68
|
+
return [];
|
|
69
|
+
const rows = this.store.db
|
|
70
|
+
.prepare(
|
|
71
|
+
"SELECT c.child_attempt, c.authority FROM task_critics r JOIN child_tasks c ON c.id=r.child_id WHERE r.task_id=? AND r.plan_revision=? AND r.state='running' AND c.parent_session=?",
|
|
72
|
+
)
|
|
73
|
+
.all(task.id, task.plan_revision, task.session_id) as {
|
|
74
|
+
child_attempt: string;
|
|
75
|
+
authority: string;
|
|
76
|
+
}[];
|
|
77
|
+
return rows
|
|
78
|
+
.filter((r) => {
|
|
79
|
+
try {
|
|
80
|
+
const tools = JSON.parse(r.authority) as unknown;
|
|
81
|
+
return (
|
|
82
|
+
Array.isArray(tools) &&
|
|
83
|
+
tools.every((t) => (READ_ONLY_REVIEW_TOOLS as readonly unknown[]).includes(t))
|
|
84
|
+
);
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
.map((r) => r.child_attempt);
|
|
90
|
+
}
|
|
91
|
+
/** Live work in the task's session tree. `verification` leaves out a running read-only review's
|
|
92
|
+
* child attempt, for verification reads only; admission and concurrency count everything. */
|
|
93
|
+
active(task: WorkTask, opts: { verification?: boolean } = {}): number {
|
|
94
|
+
const excluded = JSON.stringify(opts.verification ? this.readOnlyReviews(task) : []);
|
|
50
95
|
const row = this.store.db
|
|
51
96
|
.prepare(
|
|
52
97
|
`WITH tree(id) AS (SELECT ? UNION ALL SELECT child_session FROM child_tasks WHERE parent_session=?) SELECT
|
|
53
|
-
(SELECT count(*) FROM attempts WHERE session_id IN (SELECT id FROM tree) AND state IN ('accepted','dispatching','running')) +
|
|
98
|
+
(SELECT count(*) FROM attempts WHERE session_id IN (SELECT id FROM tree) AND state IN ('accepted','dispatching','running') AND id NOT IN (SELECT value FROM json_each(?))) +
|
|
54
99
|
(SELECT count(*) FROM station_calls WHERE session_id IN (SELECT id FROM tree) AND kind='check' AND attempt_id IS NULL AND state IN ('prepared','dispatching')) +
|
|
55
100
|
(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')) +
|
|
56
101
|
(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`,
|
|
57
102
|
)
|
|
58
|
-
.get(task.session_id, task.session_id, task.session_id)!;
|
|
103
|
+
.get(task.session_id, task.session_id, excluded, task.session_id)!;
|
|
59
104
|
return Number(row.n);
|
|
60
105
|
}
|
|
61
106
|
private deny(session: string, message: string): never {
|
package/src/task-context.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import type { TaskWork } from './task-work.js';
|
|
3
3
|
import type { ContextAdmission } from './context-admission.js';
|
|
4
|
+
import type { ProjectInstructions } from './project-instructions.js';
|
|
4
5
|
import type { Store } from './store.js';
|
|
5
6
|
import type { Artifacts } from './artifacts.js';
|
|
6
7
|
import { canonical, ServiceError, string, integer, object } from './protocol.js';
|
|
@@ -41,6 +42,10 @@ export interface Compaction {
|
|
|
41
42
|
export class TaskContext {
|
|
42
43
|
work?: TaskWork;
|
|
43
44
|
admitted?: ContextAdmission;
|
|
45
|
+
/** Interactive sessions only: AGENTS.md guidance loaded automatically (project-instructions.ts). */
|
|
46
|
+
instructions?: ProjectInstructions;
|
|
47
|
+
/** Interactive sessions only: a folder checkpoint before every turn (turn-rewind.ts). */
|
|
48
|
+
checkpoints?: import('./turn-rewind.js').TurnCheckpoints;
|
|
44
49
|
constructor(
|
|
45
50
|
readonly store: Store,
|
|
46
51
|
readonly artifacts: Artifacts,
|
|
@@ -99,11 +104,13 @@ export class TaskContext {
|
|
|
99
104
|
if (Buffer.byteLength(frame) > TASK_FRAME_BYTES)
|
|
100
105
|
throw new ServiceError('context_capacity', 'Mandatory task state exceeds the context budget');
|
|
101
106
|
const admittedContext = this.admitted?.frame(id);
|
|
102
|
-
|
|
107
|
+
const projectInstructions = this.instructions?.frame(id);
|
|
108
|
+
return admittedContext || projectInstructions
|
|
103
109
|
? 'SALAAM_TASK_STATE ' +
|
|
104
110
|
canonical({
|
|
105
111
|
...JSON.parse(frame.slice('SALAAM_TASK_STATE '.length)),
|
|
106
|
-
admitted_context: admittedContext,
|
|
112
|
+
...(admittedContext ? { admitted_context: admittedContext } : {}),
|
|
113
|
+
...(projectInstructions ? { project_instructions: projectInstructions } : {}),
|
|
107
114
|
})
|
|
108
115
|
: frame;
|
|
109
116
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { Store } from './store.js';
|
|
3
|
+
import type { Artifacts } from './artifacts.js';
|
|
4
|
+
import type { AgentGrant } from './agent-profiles.js';
|
|
5
|
+
import type { WorkTask } from './task-work.js';
|
|
6
|
+
import type { WorkspaceTree } from './workspaces.js';
|
|
7
|
+
import type { ChildTask } from './protocol.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The completion critic (item 8). When the admitted plan declares a critic, the built-in reviewer
|
|
11
|
+
* reads an immutable snapshot of the verified deliverables against the plan's rubric before the
|
|
12
|
+
* task may complete. Its verdict is bound to the exact verified evidence (checks and tree): any
|
|
13
|
+
* later change makes it stale. A critical or high finding means changes are needed; a reviewer
|
|
14
|
+
* that reports nothing at all is a failure, never a pass.
|
|
15
|
+
*/
|
|
16
|
+
export const CRITIC_BUDGET = 4;
|
|
17
|
+
export const CRITIC_REOPENS = 2;
|
|
18
|
+
export type CriticVerdict =
|
|
19
|
+
| { state: 'pass' }
|
|
20
|
+
| { state: 'pending' }
|
|
21
|
+
| { state: 'changes_needed'; findings: CriticFinding[] }
|
|
22
|
+
| { state: 'failed'; reason: string };
|
|
23
|
+
export interface CriticFinding {
|
|
24
|
+
path: string;
|
|
25
|
+
line: number;
|
|
26
|
+
severity: string;
|
|
27
|
+
message: string;
|
|
28
|
+
}
|
|
29
|
+
export interface CriticDeps {
|
|
30
|
+
admit(task: WorkTask, request: string): Promise<AgentGrant>;
|
|
31
|
+
spawn(
|
|
32
|
+
task: WorkTask,
|
|
33
|
+
parentAttempt: string,
|
|
34
|
+
step: string,
|
|
35
|
+
prompt: string,
|
|
36
|
+
budget: number,
|
|
37
|
+
grant: AgentGrant,
|
|
38
|
+
snapshot: WorkspaceTree,
|
|
39
|
+
): ChildTask;
|
|
40
|
+
workerId(childId: string): string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class TaskCritic {
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly store: Store,
|
|
46
|
+
private readonly artifacts: Artifacts,
|
|
47
|
+
private readonly deps: CriticDeps,
|
|
48
|
+
) {
|
|
49
|
+
store.db.exec(`CREATE TABLE IF NOT EXISTS task_critics(
|
|
50
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
51
|
+
task_id TEXT NOT NULL,
|
|
52
|
+
plan_revision INTEGER NOT NULL,
|
|
53
|
+
evidence_digest TEXT NOT NULL,
|
|
54
|
+
round INTEGER NOT NULL,
|
|
55
|
+
child_id TEXT NOT NULL,
|
|
56
|
+
state TEXT NOT NULL,
|
|
57
|
+
findings TEXT,
|
|
58
|
+
reason TEXT,
|
|
59
|
+
UNIQUE(task_id, plan_revision, evidence_digest))`);
|
|
60
|
+
}
|
|
61
|
+
/** Whether this exact verified evidence has a passing critic verdict. */
|
|
62
|
+
passed(task: WorkTask, evidenceDigest: string): boolean {
|
|
63
|
+
return (
|
|
64
|
+
this.store.db
|
|
65
|
+
.prepare(
|
|
66
|
+
"SELECT 1 FROM task_critics WHERE task_id=? AND plan_revision=? AND evidence_digest=? AND state='pass'",
|
|
67
|
+
)
|
|
68
|
+
.get(task.id, task.plan_revision, evidenceDigest) !== undefined
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
/** How many times the critic has sent this plan revision back for changes. */
|
|
72
|
+
reopens(task: WorkTask): number {
|
|
73
|
+
return Number(
|
|
74
|
+
this.store.db
|
|
75
|
+
.prepare(
|
|
76
|
+
"SELECT count(*) AS n FROM task_critics WHERE task_id=? AND plan_revision=? AND state='changes_needed'",
|
|
77
|
+
)
|
|
78
|
+
.get(task.id, task.plan_revision)!.n,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
/** The verdict for this verified evidence, starting the reviewer the first time it is asked. */
|
|
82
|
+
async gate(
|
|
83
|
+
task: WorkTask,
|
|
84
|
+
evidenceDigest: string,
|
|
85
|
+
deliverables: { path: string; content: string }[],
|
|
86
|
+
): Promise<CriticVerdict> {
|
|
87
|
+
const critic = task.completion_gate?.critic;
|
|
88
|
+
if (!critic) return { state: 'pass' };
|
|
89
|
+
const row = this.store.db
|
|
90
|
+
.prepare(
|
|
91
|
+
'SELECT * FROM task_critics WHERE task_id=? AND plan_revision=? AND evidence_digest=?',
|
|
92
|
+
)
|
|
93
|
+
.get(task.id, task.plan_revision, evidenceDigest);
|
|
94
|
+
if (row) return this.settle(row);
|
|
95
|
+
const last = this.store.db
|
|
96
|
+
.prepare(
|
|
97
|
+
'SELECT b.attempt_id FROM task_step_attempts b JOIN task_steps s ON s.id=b.step_id WHERE s.task_id=? ORDER BY b.rowid DESC LIMIT 1',
|
|
98
|
+
)
|
|
99
|
+
.get(task.id);
|
|
100
|
+
if (!last) return { state: 'failed', reason: 'No step attempt for the critic to follow' };
|
|
101
|
+
if (!deliverables.length)
|
|
102
|
+
return { state: 'failed', reason: 'No verified deliverables to review' };
|
|
103
|
+
const round =
|
|
104
|
+
Number(
|
|
105
|
+
this.store.db
|
|
106
|
+
.prepare('SELECT count(*) AS n FROM task_critics WHERE task_id=? AND plan_revision=?')
|
|
107
|
+
.get(task.id, task.plan_revision)!.n,
|
|
108
|
+
) + 1;
|
|
109
|
+
const snapshot: WorkspaceTree = Object.fromEntries(
|
|
110
|
+
deliverables.map((d) => [
|
|
111
|
+
d.path,
|
|
112
|
+
{ content: Buffer.from(d.content, 'utf8').toString('base64'), executable: false },
|
|
113
|
+
]),
|
|
114
|
+
);
|
|
115
|
+
const grant = await this.deps.admit(task, `critic:${task.id}:${task.plan_revision}`);
|
|
116
|
+
const child = this.deps.spawn(
|
|
117
|
+
task,
|
|
118
|
+
String(last.attempt_id),
|
|
119
|
+
`critic-${task.plan_revision}-${round}`,
|
|
120
|
+
[
|
|
121
|
+
'Judge the delivered work against this rubric before it is accepted:',
|
|
122
|
+
critic.rubric,
|
|
123
|
+
'',
|
|
124
|
+
'Objective: ' + task.objective,
|
|
125
|
+
'Deliverables (the snapshot): ' + deliverables.map((d) => d.path).join(', '),
|
|
126
|
+
'',
|
|
127
|
+
'Report findings with salaam_findings, citing the file and line. Use critical or high only',
|
|
128
|
+
'for what must change before the work is accepted; medium or low for suggestions. If the',
|
|
129
|
+
'work meets the rubric, report an empty list.',
|
|
130
|
+
].join('\n'),
|
|
131
|
+
CRITIC_BUDGET,
|
|
132
|
+
grant,
|
|
133
|
+
snapshot,
|
|
134
|
+
);
|
|
135
|
+
this.store.db
|
|
136
|
+
.prepare("INSERT INTO task_critics VALUES (?,?,?,?,?,?,'running',NULL,NULL)")
|
|
137
|
+
.run(randomUUID(), task.id, task.plan_revision, evidenceDigest, round, child.id);
|
|
138
|
+
return { state: 'pending' };
|
|
139
|
+
}
|
|
140
|
+
private settle(row: Record<string, unknown>): CriticVerdict {
|
|
141
|
+
if (row.state === 'pass') return { state: 'pass' };
|
|
142
|
+
if (row.state === 'changes_needed')
|
|
143
|
+
return { state: 'changes_needed', findings: JSON.parse(String(row.findings)) };
|
|
144
|
+
if (row.state === 'failed') return { state: 'failed', reason: String(row.reason) };
|
|
145
|
+
const child = this.store.db
|
|
146
|
+
.prepare('SELECT state FROM child_tasks WHERE id=?')
|
|
147
|
+
.get(String(row.child_id));
|
|
148
|
+
if (!child || child.state === 'accepted') return { state: 'pending' };
|
|
149
|
+
const report = this.store.db
|
|
150
|
+
.prepare('SELECT artifact_id FROM specialist_reports WHERE worker_id=?')
|
|
151
|
+
.get(this.deps.workerId(String(row.child_id)));
|
|
152
|
+
const set = (state: string, findings: CriticFinding[] | null, reason: string | null) =>
|
|
153
|
+
this.store.db
|
|
154
|
+
.prepare('UPDATE task_critics SET state=?,findings=?,reason=? WHERE id=?')
|
|
155
|
+
.run(state, findings ? JSON.stringify(findings) : null, reason, String(row.id));
|
|
156
|
+
if (!report) {
|
|
157
|
+
const reason = 'The critic returned no findings report';
|
|
158
|
+
set('failed', null, reason);
|
|
159
|
+
return { state: 'failed', reason };
|
|
160
|
+
}
|
|
161
|
+
const findings = (
|
|
162
|
+
JSON.parse(this.artifacts.read(String(report.artifact_id))).findings as CriticFinding[]
|
|
163
|
+
).map(({ path, line, severity, message }) => ({ path, line, severity, message }));
|
|
164
|
+
if (findings.some((f) => f.severity === 'critical' || f.severity === 'high')) {
|
|
165
|
+
set('changes_needed', findings, null);
|
|
166
|
+
return { state: 'changes_needed', findings };
|
|
167
|
+
}
|
|
168
|
+
set('pass', findings, null);
|
|
169
|
+
return { state: 'pass' };
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/task-driver.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { CRITIC_REOPENS } from './task-critic.js';
|
|
2
|
+
import { requiredChecks } from './task-policy.js';
|
|
3
|
+
import { RATE_LIMIT_STOP, RateLimitRetries } from './rate-limit-retries.js';
|
|
1
4
|
import { randomUUID } from 'node:crypto';
|
|
2
5
|
import type { Store } from './store.js';
|
|
3
6
|
import type { TaskWork, WorkTask } from './task-work.js';
|
|
@@ -15,6 +18,7 @@ export class TaskDriver {
|
|
|
15
18
|
awaitingAuthorityRenewal?: (session: string) => boolean;
|
|
16
19
|
private active?: Promise<void>;
|
|
17
20
|
private stopped = true;
|
|
21
|
+
readonly rateLimits: RateLimitRetries;
|
|
18
22
|
constructor(
|
|
19
23
|
readonly store: Store,
|
|
20
24
|
readonly tasks: TaskWork,
|
|
@@ -24,6 +28,7 @@ export class TaskDriver {
|
|
|
24
28
|
readonly children: ChildWork,
|
|
25
29
|
readonly coding: CodingWork,
|
|
26
30
|
) {
|
|
31
|
+
this.rateLimits = new RateLimitRetries(store);
|
|
27
32
|
tasks.assertProgressionBinding = (task, binding) => {
|
|
28
33
|
if (canonical(this.verification.binding(task)) !== binding)
|
|
29
34
|
throw new ServiceError(
|
|
@@ -374,6 +379,20 @@ export class TaskDriver {
|
|
|
374
379
|
return;
|
|
375
380
|
}
|
|
376
381
|
if (task.steps.some((s) => ['interrupted', 'failed', 'cancelled'].includes(s.state))) {
|
|
382
|
+
// A provider 429 is retried as a fresh attempt of the step after a backoff; past the limit,
|
|
383
|
+
// or with no progression step left, the task stops with a block recovery can settle.
|
|
384
|
+
const retry = this.rateLimits.decide(task);
|
|
385
|
+
if (retry.action === 'wait') return;
|
|
386
|
+
if (retry.action === 'retry' && Number(r.remaining_steps) >= 1) {
|
|
387
|
+
if (!(await this.worker(task)) || this.stopped) return;
|
|
388
|
+
this.tasks.retryStep(task.id, retry.step, retry.n, Number(r.auto_epoch));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (retry.action !== 'none') {
|
|
392
|
+
this.budget.block(task.session_id, RATE_LIMIT_STOP);
|
|
393
|
+
this.stop(task, RATE_LIMIT_STOP);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
377
396
|
this.stop(task, 'An interrupted or failed step requires explicit operator review');
|
|
378
397
|
return;
|
|
379
398
|
}
|
|
@@ -420,13 +439,41 @@ export class TaskDriver {
|
|
|
420
439
|
// the native workspace. Wait for the whole set before testing freshness;
|
|
421
440
|
// finishAsync still performs the final transactional evidence validation.
|
|
422
441
|
if (
|
|
423
|
-
task.
|
|
442
|
+
requiredChecks(task).some((check) => {
|
|
424
443
|
const receipt = this.verification.latest(task, check.key, null);
|
|
425
444
|
return receipt?.state === 'accepted' || receipt?.state === 'running';
|
|
426
445
|
})
|
|
427
446
|
)
|
|
428
447
|
return;
|
|
429
|
-
for (const check of
|
|
448
|
+
for (const check of requiredChecks(task))
|
|
449
|
+
if (!(await this.check(task, check.key, null))) return;
|
|
450
|
+
// Item 8: a declared critic reviews the verified deliverables before completion.
|
|
451
|
+
if (task.completion_gate?.critic) {
|
|
452
|
+
const verdict = await this.verification.criticGateAsync(task);
|
|
453
|
+
if (verdict.state === 'pending') return;
|
|
454
|
+
if (verdict.state === 'failed') {
|
|
455
|
+
this.stop(task, 'Critic review failed: ' + verdict.reason);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (verdict.state === 'changes_needed') {
|
|
459
|
+
const round = this.verification.critic!.reopens(task);
|
|
460
|
+
if (round > CRITIC_REOPENS || Number(r.remaining_steps) < 1) {
|
|
461
|
+
this.stop(task, 'Critic requested changes');
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (!(await this.worker(task)) || this.stopped) return;
|
|
465
|
+
this.tasks.reopenForCritic(
|
|
466
|
+
task.id,
|
|
467
|
+
task.steps.at(-1)!.id,
|
|
468
|
+
round,
|
|
469
|
+
Number(r.auto_epoch),
|
|
470
|
+
verdict.findings
|
|
471
|
+
.map((f) => `- ${f.path}:${f.line} (${f.severity}) ${f.message}`)
|
|
472
|
+
.join('\n'),
|
|
473
|
+
);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
430
477
|
await this.verification.finishAsync(
|
|
431
478
|
task.id,
|
|
432
479
|
'auto-finish:' + task.id + ':' + task.plan_revision,
|
package/src/task-policy.ts
CHANGED
|
@@ -17,11 +17,41 @@ export interface TaskLimits {
|
|
|
17
17
|
output_token_bound: number | null;
|
|
18
18
|
verification_runs: number;
|
|
19
19
|
}
|
|
20
|
+
/** Item 8: checks required at completion, next to the acceptance checks. The critic is not yet
|
|
21
|
+
* admitted, so it is always null. */
|
|
22
|
+
export interface CompletionGate {
|
|
23
|
+
/** The built-in reviewer judges the verified deliverables against this rubric. */
|
|
24
|
+
critic: { rubric: string } | null;
|
|
25
|
+
hooks: TaskCheck[];
|
|
26
|
+
}
|
|
20
27
|
export interface TaskPolicy {
|
|
21
28
|
acceptance: TaskCheck[];
|
|
22
29
|
deliverables: string[];
|
|
23
30
|
limits: TaskLimits;
|
|
31
|
+
/** Present only when the plan declares one, so a plan without it keeps its exact shape. */
|
|
32
|
+
completion_gate?: CompletionGate;
|
|
33
|
+
}
|
|
34
|
+
/** Every check completion requires: the acceptance checks, then each hook as hook-<key>. */
|
|
35
|
+
export function requiredChecks(
|
|
36
|
+
policy: Pick<TaskPolicy, 'acceptance' | 'completion_gate'>,
|
|
37
|
+
): TaskCheck[] {
|
|
38
|
+
return [
|
|
39
|
+
...policy.acceptance,
|
|
40
|
+
...(policy.completion_gate?.hooks ?? []).map((h) => ({ ...h, key: 'hook-' + h.key })),
|
|
41
|
+
];
|
|
24
42
|
}
|
|
43
|
+
const check = (v: unknown, keys: Set<string>, what: string): TaskCheck => {
|
|
44
|
+
const c = object(v),
|
|
45
|
+
key = string(c.key, what + ' key', 64);
|
|
46
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(key) || keys.has(key))
|
|
47
|
+
throw new ServiceError('invalid_plan', what + ' keys must be unique identifiers', 400);
|
|
48
|
+
keys.add(key);
|
|
49
|
+
return {
|
|
50
|
+
key,
|
|
51
|
+
command: string(c.command, what + ' command', 4096),
|
|
52
|
+
cwd: c.cwd === undefined || c.cwd === '.' ? '.' : filePath(c.cwd),
|
|
53
|
+
};
|
|
54
|
+
};
|
|
25
55
|
export const DEFAULT_TASK_LIMITS: TaskLimits = {
|
|
26
56
|
requests: 64,
|
|
27
57
|
runtime_ms: 900000,
|
|
@@ -36,18 +66,7 @@ export function taskPolicy(value: Record<string, unknown>): TaskPolicy {
|
|
|
36
66
|
if (!Array.isArray(raw) || raw.length > 8)
|
|
37
67
|
throw new ServiceError('invalid_plan', 'Supply at most eight acceptance checks', 400);
|
|
38
68
|
const keys = new Set<string>();
|
|
39
|
-
const acceptance = raw.map((v) =>
|
|
40
|
-
const c = object(v),
|
|
41
|
-
key = string(c.key, 'check key', 64);
|
|
42
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(key) || keys.has(key))
|
|
43
|
-
throw new ServiceError('invalid_plan', 'Acceptance keys must be unique identifiers', 400);
|
|
44
|
-
keys.add(key);
|
|
45
|
-
return {
|
|
46
|
-
key,
|
|
47
|
-
command: string(c.command, 'check command', 4096),
|
|
48
|
-
cwd: c.cwd === undefined || c.cwd === '.' ? '.' : filePath(c.cwd),
|
|
49
|
-
};
|
|
50
|
-
});
|
|
69
|
+
const acceptance = raw.map((v) => check(v, keys, 'Acceptance'));
|
|
51
70
|
const paths = value.deliverables ?? [];
|
|
52
71
|
if (!Array.isArray(paths) || paths.length > 32)
|
|
53
72
|
throw new ServiceError('invalid_plan', 'Supply at most 32 deliverable paths', 400);
|
|
@@ -88,5 +107,38 @@ export function taskPolicy(value: Record<string, unknown>): TaskPolicy {
|
|
|
88
107
|
throw new ServiceError('invalid_budget', 'Invocation limit must be 1–64', 400);
|
|
89
108
|
limits.invocations = n;
|
|
90
109
|
}
|
|
91
|
-
|
|
110
|
+
let completion_gate: CompletionGate | undefined;
|
|
111
|
+
if (value.completion_gate !== undefined) {
|
|
112
|
+
const g = object(value.completion_gate);
|
|
113
|
+
const hooks = g.hooks;
|
|
114
|
+
const c = g.critic;
|
|
115
|
+
const critic =
|
|
116
|
+
c === null
|
|
117
|
+
? null
|
|
118
|
+
: (() => {
|
|
119
|
+
const v = object(c);
|
|
120
|
+
if (Object.keys(v).join(',') !== 'rubric')
|
|
121
|
+
throw new ServiceError('invalid_plan', 'A critic names only its rubric', 400);
|
|
122
|
+
return { rubric: string(v.rubric, 'critic rubric', 4096) };
|
|
123
|
+
})();
|
|
124
|
+
if (
|
|
125
|
+
Object.keys(g).sort().join(',') !== 'critic,hooks' ||
|
|
126
|
+
!Array.isArray(hooks) ||
|
|
127
|
+
hooks.length > 8 ||
|
|
128
|
+
(!critic && hooks.length < 1)
|
|
129
|
+
)
|
|
130
|
+
throw new ServiceError(
|
|
131
|
+
'invalid_plan',
|
|
132
|
+
'A completion gate has a critic or 1–8 hooks, and at most 8 hooks',
|
|
133
|
+
400,
|
|
134
|
+
);
|
|
135
|
+
const hookKeys = new Set<string>();
|
|
136
|
+
completion_gate = { critic, hooks: hooks.map((v) => check(v, hookKeys, 'Hook')) };
|
|
137
|
+
if (
|
|
138
|
+
requiredChecks({ acceptance, completion_gate }).length !==
|
|
139
|
+
new Set(requiredChecks({ acceptance, completion_gate }).map((c) => c.key)).size
|
|
140
|
+
)
|
|
141
|
+
throw new ServiceError('invalid_plan', 'A hook collides with an acceptance check', 400);
|
|
142
|
+
}
|
|
143
|
+
return { acceptance, deliverables, limits, ...(completion_gate ? { completion_gate } : {}) };
|
|
92
144
|
}
|
package/src/task-verification.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { TaskCritic, CriticVerdict } from './task-critic.js';
|
|
2
|
+
import { requiredChecks } from './task-policy.js';
|
|
1
3
|
import { StationTaskVerification, type StationCheckEvidence } from './station-task-verification.js';
|
|
2
4
|
import type { StationWork } from './station-work.js';
|
|
3
5
|
import { randomUUID } from 'node:crypto';
|
|
@@ -63,7 +65,7 @@ export class TaskVerification {
|
|
|
63
65
|
}
|
|
64
66
|
assertIdle(task: WorkTask, stationInspection?: string): void {
|
|
65
67
|
if (
|
|
66
|
-
this.budget.active(task) > 0 ||
|
|
68
|
+
this.budget.active(task, { verification: true }) > 0 ||
|
|
67
69
|
this.store.db
|
|
68
70
|
.prepare("SELECT 1 FROM compactions WHERE session_id=? AND state='running'")
|
|
69
71
|
.get(task.session_id)
|
|
@@ -156,7 +158,7 @@ export class TaskVerification {
|
|
|
156
158
|
throw new ServiceError('task_settled', 'Revise a cancelled task before completing it');
|
|
157
159
|
this.assertIdle(task);
|
|
158
160
|
if (!this.station) this.coding.assertSafe(task.session_id);
|
|
159
|
-
if (!task.
|
|
161
|
+
if (!requiredChecks(task).some((c) => c.key === key))
|
|
160
162
|
throw new ServiceError('invalid_check', 'Check is not in this task plan');
|
|
161
163
|
if (
|
|
162
164
|
step &&
|
|
@@ -215,7 +217,7 @@ export class TaskVerification {
|
|
|
215
217
|
return;
|
|
216
218
|
}
|
|
217
219
|
const task = this.tasks.get(receipt.task_id),
|
|
218
|
-
check = task.
|
|
220
|
+
check = requiredChecks(task).find((c) => c.key === receipt.check_key);
|
|
219
221
|
try {
|
|
220
222
|
const existing = this.store.db
|
|
221
223
|
.prepare('SELECT id FROM command_runs WHERE session_id=? AND step_key=?')
|
|
@@ -485,6 +487,32 @@ export class TaskVerification {
|
|
|
485
487
|
);
|
|
486
488
|
return this.finishRecorded(id, request, expected, exception, authorize);
|
|
487
489
|
}
|
|
490
|
+
/** Item 8: the completion critic, when the service has one. */
|
|
491
|
+
critic?: TaskCritic;
|
|
492
|
+
/** The critic's verdict on the current fresh verified evidence, starting it the first time.
|
|
493
|
+
* Reads the evidence outside the finishing transaction, so starting the reviewer can never be
|
|
494
|
+
* rolled back with a refused finish; the finish itself re-checks the verdict against its own
|
|
495
|
+
* evidence. */
|
|
496
|
+
async criticGateAsync(task: WorkTask): Promise<CriticVerdict> {
|
|
497
|
+
if (!task.completion_gate?.critic) return { state: 'pass' };
|
|
498
|
+
if (!this.critic || !this.station)
|
|
499
|
+
return { state: 'failed', reason: 'A critic needs the station verification boundary' };
|
|
500
|
+
const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
|
|
501
|
+
if (checks.some((c) => !c || c.state !== 'passed')) return { state: 'pending' };
|
|
502
|
+
const fresh = await this.station.withFreshMany(
|
|
503
|
+
checks.map((c) => c!.id),
|
|
504
|
+
task.deliverables,
|
|
505
|
+
(evidence, deliverables) => ({ evidence, deliverables }),
|
|
506
|
+
);
|
|
507
|
+
return this.critic.gate(
|
|
508
|
+
task,
|
|
509
|
+
digest(fresh.evidence),
|
|
510
|
+
(fresh.deliverables as { path: string; content: string }[]).map(({ path, content }) => ({
|
|
511
|
+
path,
|
|
512
|
+
content,
|
|
513
|
+
})),
|
|
514
|
+
);
|
|
515
|
+
}
|
|
488
516
|
async finishAsync(
|
|
489
517
|
id: string,
|
|
490
518
|
request: string,
|
|
@@ -521,7 +549,7 @@ export class TaskVerification {
|
|
|
521
549
|
'verification_required',
|
|
522
550
|
'Configured checks and deliverables required',
|
|
523
551
|
);
|
|
524
|
-
const checks = task.
|
|
552
|
+
const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
|
|
525
553
|
if (checks.some((c) => !c || c.state !== 'passed'))
|
|
526
554
|
throw new ServiceError('verification_required', 'Passed configured checks required');
|
|
527
555
|
return this.station.withFreshMany(
|
|
@@ -541,7 +569,7 @@ export class TaskVerification {
|
|
|
541
569
|
if (view.completion?.state === 'succeeded') {
|
|
542
570
|
try {
|
|
543
571
|
const evidence = JSON.parse(this.artifacts.read(String(view.completion.artifact_id)));
|
|
544
|
-
const checks = task.
|
|
572
|
+
const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
|
|
545
573
|
if (checks.some((c) => !c || c.state !== 'passed' || !evidence.checks.includes(c.id)))
|
|
546
574
|
throw Error('Changed checks');
|
|
547
575
|
view.completion.fresh = await this.station.withFreshMany(
|
|
@@ -578,7 +606,7 @@ export class TaskVerification {
|
|
|
578
606
|
this.assertIdle(task);
|
|
579
607
|
if (task.steps.some((s) => s.state !== 'completed'))
|
|
580
608
|
throw new ServiceError('verification_required', 'Every step must be reviewed or verified');
|
|
581
|
-
const checks = task.
|
|
609
|
+
const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
|
|
582
610
|
const missing: string[] = [];
|
|
583
611
|
if (!task.acceptance.length) missing.push('No configured acceptance checks');
|
|
584
612
|
if (!task.deliverables.length) missing.push('No configured deliverable paths');
|
|
@@ -589,7 +617,7 @@ export class TaskVerification {
|
|
|
589
617
|
? !protectedResult.evidence.some((e) => e.check_id === checks[n]!.id)
|
|
590
618
|
: !this.fresh(checks[n]!))
|
|
591
619
|
)
|
|
592
|
-
missing.push('Fresh check required: ' + task
|
|
620
|
+
missing.push('Fresh check required: ' + requiredChecks(task)[n]!.key);
|
|
593
621
|
let workspace: { id: string; revision: number; digest: string } | null = null,
|
|
594
622
|
deliverables: unknown[] = [],
|
|
595
623
|
diff: unknown = null;
|
|
@@ -597,7 +625,17 @@ export class TaskVerification {
|
|
|
597
625
|
if (protectedResult) {
|
|
598
626
|
const e = protectedResult.evidence[0]!;
|
|
599
627
|
workspace = { id: 'station:' + e.root_digest, revision: 0, digest: e.tree_digest };
|
|
600
|
-
|
|
628
|
+
// Keep each deliverable's verified bytes as a session artifact, committed with the
|
|
629
|
+
// completion, so publication reads the verified revision and never the live workspace.
|
|
630
|
+
deliverables = (
|
|
631
|
+
protectedResult.deliverables as { path: string; sha256: string; content: string }[]
|
|
632
|
+
).map(({ path, sha256, content }) => {
|
|
633
|
+
if (typeof content !== 'string')
|
|
634
|
+
throw new ServiceError('verification_required', 'Deliverable bytes unavailable');
|
|
635
|
+
const artifact = this.artifacts.prepare(task.session_id, content);
|
|
636
|
+
artifact.commit();
|
|
637
|
+
return { path, sha256, artifact_id: artifact.id };
|
|
638
|
+
});
|
|
601
639
|
diff = {
|
|
602
640
|
backend: 'linux-systemd-station-v1',
|
|
603
641
|
root_digest: e.root_digest,
|
|
@@ -617,6 +655,12 @@ export class TaskVerification {
|
|
|
617
655
|
} catch (error) {
|
|
618
656
|
missing.push('Workspace verification unavailable: ' + (error as Error).message);
|
|
619
657
|
}
|
|
658
|
+
// Item 8: a declared critic must have passed this exact verified evidence.
|
|
659
|
+
if (
|
|
660
|
+
task.completion_gate?.critic &&
|
|
661
|
+
!(protectedResult && this.critic?.passed(task, digest(protectedResult.evidence)))
|
|
662
|
+
)
|
|
663
|
+
missing.push('Critic review required');
|
|
620
664
|
if (missing.length && !exception)
|
|
621
665
|
throw new ServiceError('verification_required', missing.join('; '));
|
|
622
666
|
if (exception) {
|
|
@@ -702,7 +746,7 @@ export class TaskVerification {
|
|
|
702
746
|
if (completion.state === 'succeeded')
|
|
703
747
|
fresh =
|
|
704
748
|
fresh &&
|
|
705
|
-
task.
|
|
749
|
+
requiredChecks(task).every((check) => {
|
|
706
750
|
const latest = this.latest(task, check.key, null);
|
|
707
751
|
return !!latest && evidence.checks.includes(latest.id) && this.fresh(latest);
|
|
708
752
|
});
|