@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/dist/src/task-budget.js
CHANGED
|
@@ -6,6 +6,12 @@ export class TaskBudgetError extends ServiceError {
|
|
|
6
6
|
this.session_id = session_id;
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
|
+
/** Tools a critic review child may hold and still be left out of the verification busy check. */
|
|
10
|
+
export const READ_ONLY_REVIEW_TOOLS = [
|
|
11
|
+
'salaam_findings',
|
|
12
|
+
'salaam_probe',
|
|
13
|
+
'salaam_repository',
|
|
14
|
+
];
|
|
9
15
|
export class TaskBudget {
|
|
10
16
|
store;
|
|
11
17
|
tasks;
|
|
@@ -23,6 +29,8 @@ export class TaskBudget {
|
|
|
23
29
|
store.taskChildFinished = (attempt) => this.releaseChild(attempt);
|
|
24
30
|
store.taskCommandAdmission = (session) => this.command(session);
|
|
25
31
|
store.taskRemainingRuntime = (session) => this.remaining(session);
|
|
32
|
+
// Only the task's own session: a child's attempt keeps the budget its reservation set.
|
|
33
|
+
store.taskAttemptRequestLimit = (session) => this.tasks.forSession(session)?.limits.requests;
|
|
26
34
|
}
|
|
27
35
|
task(session) {
|
|
28
36
|
const direct = this.tasks.forSession(session);
|
|
@@ -39,14 +47,42 @@ export class TaskBudget {
|
|
|
39
47
|
.n);
|
|
40
48
|
return { ...runtime, invocations_spent: n, external_model_usage: n ? 'unknown' : 'none' };
|
|
41
49
|
}
|
|
42
|
-
|
|
50
|
+
/** The child attempt of each running critic review of this task revision whose recorded
|
|
51
|
+
* tools are all read-only (operator ruling 2026-10-30, option A). Only verification reads may
|
|
52
|
+
* leave these out of the busy count: the reviewer reads a digest-bound snapshot and cannot
|
|
53
|
+
* write the workspace, run a command or reach the station (staging card_019a647a169744ff). */
|
|
54
|
+
readOnlyReviews(task) {
|
|
55
|
+
if (!this.store.db
|
|
56
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_critics'")
|
|
57
|
+
.get())
|
|
58
|
+
return [];
|
|
59
|
+
const rows = this.store.db
|
|
60
|
+
.prepare("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=?")
|
|
61
|
+
.all(task.id, task.plan_revision, task.session_id);
|
|
62
|
+
return rows
|
|
63
|
+
.filter((r) => {
|
|
64
|
+
try {
|
|
65
|
+
const tools = JSON.parse(r.authority);
|
|
66
|
+
return (Array.isArray(tools) &&
|
|
67
|
+
tools.every((t) => READ_ONLY_REVIEW_TOOLS.includes(t)));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
.map((r) => r.child_attempt);
|
|
74
|
+
}
|
|
75
|
+
/** Live work in the task's session tree. `verification` leaves out a running read-only review's
|
|
76
|
+
* child attempt, for verification reads only; admission and concurrency count everything. */
|
|
77
|
+
active(task, opts = {}) {
|
|
78
|
+
const excluded = JSON.stringify(opts.verification ? this.readOnlyReviews(task) : []);
|
|
43
79
|
const row = this.store.db
|
|
44
80
|
.prepare(`WITH tree(id) AS (SELECT ? UNION ALL SELECT child_session FROM child_tasks WHERE parent_session=?) SELECT
|
|
45
|
-
(SELECT count(*) FROM attempts WHERE session_id IN (SELECT id FROM tree) AND state IN ('accepted','dispatching','running')) +
|
|
81
|
+
(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(?))) +
|
|
46
82
|
(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')) +
|
|
47
83
|
(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')) +
|
|
48
84
|
(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`)
|
|
49
|
-
.get(task.session_id, task.session_id, task.session_id);
|
|
85
|
+
.get(task.session_id, task.session_id, excluded, task.session_id);
|
|
50
86
|
return Number(row.n);
|
|
51
87
|
}
|
|
52
88
|
deny(session, message) {
|
package/dist/src/task-context.js
CHANGED
|
@@ -14,6 +14,10 @@ export class TaskContext {
|
|
|
14
14
|
artifacts;
|
|
15
15
|
work;
|
|
16
16
|
admitted;
|
|
17
|
+
/** Interactive sessions only: AGENTS.md guidance loaded automatically (project-instructions.ts). */
|
|
18
|
+
instructions;
|
|
19
|
+
/** Interactive sessions only: a folder checkpoint before every turn (turn-rewind.ts). */
|
|
20
|
+
checkpoints;
|
|
17
21
|
constructor(store, artifacts) {
|
|
18
22
|
this.store = store;
|
|
19
23
|
this.artifacts = artifacts;
|
|
@@ -65,11 +69,13 @@ export class TaskContext {
|
|
|
65
69
|
if (Buffer.byteLength(frame) > TASK_FRAME_BYTES)
|
|
66
70
|
throw new ServiceError('context_capacity', 'Mandatory task state exceeds the context budget');
|
|
67
71
|
const admittedContext = this.admitted?.frame(id);
|
|
68
|
-
|
|
72
|
+
const projectInstructions = this.instructions?.frame(id);
|
|
73
|
+
return admittedContext || projectInstructions
|
|
69
74
|
? 'SALAAM_TASK_STATE ' +
|
|
70
75
|
canonical({
|
|
71
76
|
...JSON.parse(frame.slice('SALAAM_TASK_STATE '.length)),
|
|
72
|
-
admitted_context: admittedContext,
|
|
77
|
+
...(admittedContext ? { admitted_context: admittedContext } : {}),
|
|
78
|
+
...(projectInstructions ? { project_instructions: projectInstructions } : {}),
|
|
73
79
|
})
|
|
74
80
|
: frame;
|
|
75
81
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* The completion critic (item 8). When the admitted plan declares a critic, the built-in reviewer
|
|
4
|
+
* reads an immutable snapshot of the verified deliverables against the plan's rubric before the
|
|
5
|
+
* task may complete. Its verdict is bound to the exact verified evidence (checks and tree): any
|
|
6
|
+
* later change makes it stale. A critical or high finding means changes are needed; a reviewer
|
|
7
|
+
* that reports nothing at all is a failure, never a pass.
|
|
8
|
+
*/
|
|
9
|
+
export const CRITIC_BUDGET = 4;
|
|
10
|
+
export const CRITIC_REOPENS = 2;
|
|
11
|
+
export class TaskCritic {
|
|
12
|
+
store;
|
|
13
|
+
artifacts;
|
|
14
|
+
deps;
|
|
15
|
+
constructor(store, artifacts, deps) {
|
|
16
|
+
this.store = store;
|
|
17
|
+
this.artifacts = artifacts;
|
|
18
|
+
this.deps = deps;
|
|
19
|
+
store.db.exec(`CREATE TABLE IF NOT EXISTS task_critics(
|
|
20
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
21
|
+
task_id TEXT NOT NULL,
|
|
22
|
+
plan_revision INTEGER NOT NULL,
|
|
23
|
+
evidence_digest TEXT NOT NULL,
|
|
24
|
+
round INTEGER NOT NULL,
|
|
25
|
+
child_id TEXT NOT NULL,
|
|
26
|
+
state TEXT NOT NULL,
|
|
27
|
+
findings TEXT,
|
|
28
|
+
reason TEXT,
|
|
29
|
+
UNIQUE(task_id, plan_revision, evidence_digest))`);
|
|
30
|
+
}
|
|
31
|
+
/** Whether this exact verified evidence has a passing critic verdict. */
|
|
32
|
+
passed(task, evidenceDigest) {
|
|
33
|
+
return (this.store.db
|
|
34
|
+
.prepare("SELECT 1 FROM task_critics WHERE task_id=? AND plan_revision=? AND evidence_digest=? AND state='pass'")
|
|
35
|
+
.get(task.id, task.plan_revision, evidenceDigest) !== undefined);
|
|
36
|
+
}
|
|
37
|
+
/** How many times the critic has sent this plan revision back for changes. */
|
|
38
|
+
reopens(task) {
|
|
39
|
+
return Number(this.store.db
|
|
40
|
+
.prepare("SELECT count(*) AS n FROM task_critics WHERE task_id=? AND plan_revision=? AND state='changes_needed'")
|
|
41
|
+
.get(task.id, task.plan_revision).n);
|
|
42
|
+
}
|
|
43
|
+
/** The verdict for this verified evidence, starting the reviewer the first time it is asked. */
|
|
44
|
+
async gate(task, evidenceDigest, deliverables) {
|
|
45
|
+
const critic = task.completion_gate?.critic;
|
|
46
|
+
if (!critic)
|
|
47
|
+
return { state: 'pass' };
|
|
48
|
+
const row = this.store.db
|
|
49
|
+
.prepare('SELECT * FROM task_critics WHERE task_id=? AND plan_revision=? AND evidence_digest=?')
|
|
50
|
+
.get(task.id, task.plan_revision, evidenceDigest);
|
|
51
|
+
if (row)
|
|
52
|
+
return this.settle(row);
|
|
53
|
+
const last = this.store.db
|
|
54
|
+
.prepare('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')
|
|
55
|
+
.get(task.id);
|
|
56
|
+
if (!last)
|
|
57
|
+
return { state: 'failed', reason: 'No step attempt for the critic to follow' };
|
|
58
|
+
if (!deliverables.length)
|
|
59
|
+
return { state: 'failed', reason: 'No verified deliverables to review' };
|
|
60
|
+
const round = Number(this.store.db
|
|
61
|
+
.prepare('SELECT count(*) AS n FROM task_critics WHERE task_id=? AND plan_revision=?')
|
|
62
|
+
.get(task.id, task.plan_revision).n) + 1;
|
|
63
|
+
const snapshot = Object.fromEntries(deliverables.map((d) => [
|
|
64
|
+
d.path,
|
|
65
|
+
{ content: Buffer.from(d.content, 'utf8').toString('base64'), executable: false },
|
|
66
|
+
]));
|
|
67
|
+
const grant = await this.deps.admit(task, `critic:${task.id}:${task.plan_revision}`);
|
|
68
|
+
const child = this.deps.spawn(task, String(last.attempt_id), `critic-${task.plan_revision}-${round}`, [
|
|
69
|
+
'Judge the delivered work against this rubric before it is accepted:',
|
|
70
|
+
critic.rubric,
|
|
71
|
+
'',
|
|
72
|
+
'Objective: ' + task.objective,
|
|
73
|
+
'Deliverables (the snapshot): ' + deliverables.map((d) => d.path).join(', '),
|
|
74
|
+
'',
|
|
75
|
+
'Report findings with salaam_findings, citing the file and line. Use critical or high only',
|
|
76
|
+
'for what must change before the work is accepted; medium or low for suggestions. If the',
|
|
77
|
+
'work meets the rubric, report an empty list.',
|
|
78
|
+
].join('\n'), CRITIC_BUDGET, grant, snapshot);
|
|
79
|
+
this.store.db
|
|
80
|
+
.prepare("INSERT INTO task_critics VALUES (?,?,?,?,?,?,'running',NULL,NULL)")
|
|
81
|
+
.run(randomUUID(), task.id, task.plan_revision, evidenceDigest, round, child.id);
|
|
82
|
+
return { state: 'pending' };
|
|
83
|
+
}
|
|
84
|
+
settle(row) {
|
|
85
|
+
if (row.state === 'pass')
|
|
86
|
+
return { state: 'pass' };
|
|
87
|
+
if (row.state === 'changes_needed')
|
|
88
|
+
return { state: 'changes_needed', findings: JSON.parse(String(row.findings)) };
|
|
89
|
+
if (row.state === 'failed')
|
|
90
|
+
return { state: 'failed', reason: String(row.reason) };
|
|
91
|
+
const child = this.store.db
|
|
92
|
+
.prepare('SELECT state FROM child_tasks WHERE id=?')
|
|
93
|
+
.get(String(row.child_id));
|
|
94
|
+
if (!child || child.state === 'accepted')
|
|
95
|
+
return { state: 'pending' };
|
|
96
|
+
const report = this.store.db
|
|
97
|
+
.prepare('SELECT artifact_id FROM specialist_reports WHERE worker_id=?')
|
|
98
|
+
.get(this.deps.workerId(String(row.child_id)));
|
|
99
|
+
const set = (state, findings, reason) => this.store.db
|
|
100
|
+
.prepare('UPDATE task_critics SET state=?,findings=?,reason=? WHERE id=?')
|
|
101
|
+
.run(state, findings ? JSON.stringify(findings) : null, reason, String(row.id));
|
|
102
|
+
if (!report) {
|
|
103
|
+
const reason = 'The critic returned no findings report';
|
|
104
|
+
set('failed', null, reason);
|
|
105
|
+
return { state: 'failed', reason };
|
|
106
|
+
}
|
|
107
|
+
const findings = JSON.parse(this.artifacts.read(String(report.artifact_id))).findings.map(({ path, line, severity, message }) => ({ path, line, severity, message }));
|
|
108
|
+
if (findings.some((f) => f.severity === 'critical' || f.severity === 'high')) {
|
|
109
|
+
set('changes_needed', findings, null);
|
|
110
|
+
return { state: 'changes_needed', findings };
|
|
111
|
+
}
|
|
112
|
+
set('pass', findings, null);
|
|
113
|
+
return { state: 'pass' };
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/src/task-driver.js
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 { commandTerminal } from './coding.js';
|
|
3
6
|
import { canonical, ServiceError } from './protocol.js';
|
|
@@ -14,6 +17,7 @@ export class TaskDriver {
|
|
|
14
17
|
awaitingAuthorityRenewal;
|
|
15
18
|
active;
|
|
16
19
|
stopped = true;
|
|
20
|
+
rateLimits;
|
|
17
21
|
constructor(store, tasks, budget, verification, supervisor, children, coding) {
|
|
18
22
|
this.store = store;
|
|
19
23
|
this.tasks = tasks;
|
|
@@ -22,6 +26,7 @@ export class TaskDriver {
|
|
|
22
26
|
this.supervisor = supervisor;
|
|
23
27
|
this.children = children;
|
|
24
28
|
this.coding = coding;
|
|
29
|
+
this.rateLimits = new RateLimitRetries(store);
|
|
25
30
|
tasks.assertProgressionBinding = (task, binding) => {
|
|
26
31
|
if (canonical(this.verification.binding(task)) !== binding)
|
|
27
32
|
throw new ServiceError('task_progression_stale', 'Workspace or execution environment changed since the bounded grant');
|
|
@@ -295,6 +300,22 @@ export class TaskDriver {
|
|
|
295
300
|
return;
|
|
296
301
|
}
|
|
297
302
|
if (task.steps.some((s) => ['interrupted', 'failed', 'cancelled'].includes(s.state))) {
|
|
303
|
+
// A provider 429 is retried as a fresh attempt of the step after a backoff; past the limit,
|
|
304
|
+
// or with no progression step left, the task stops with a block recovery can settle.
|
|
305
|
+
const retry = this.rateLimits.decide(task);
|
|
306
|
+
if (retry.action === 'wait')
|
|
307
|
+
return;
|
|
308
|
+
if (retry.action === 'retry' && Number(r.remaining_steps) >= 1) {
|
|
309
|
+
if (!(await this.worker(task)) || this.stopped)
|
|
310
|
+
return;
|
|
311
|
+
this.tasks.retryStep(task.id, retry.step, retry.n, Number(r.auto_epoch));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (retry.action !== 'none') {
|
|
315
|
+
this.budget.block(task.session_id, RATE_LIMIT_STOP);
|
|
316
|
+
this.stop(task, RATE_LIMIT_STOP);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
298
319
|
this.stop(task, 'An interrupted or failed step requires explicit operator review');
|
|
299
320
|
return;
|
|
300
321
|
}
|
|
@@ -334,14 +355,37 @@ export class TaskDriver {
|
|
|
334
355
|
// A passed receipt cannot be re-observed while another final check owns
|
|
335
356
|
// the native workspace. Wait for the whole set before testing freshness;
|
|
336
357
|
// finishAsync still performs the final transactional evidence validation.
|
|
337
|
-
if (task.
|
|
358
|
+
if (requiredChecks(task).some((check) => {
|
|
338
359
|
const receipt = this.verification.latest(task, check.key, null);
|
|
339
360
|
return receipt?.state === 'accepted' || receipt?.state === 'running';
|
|
340
361
|
}))
|
|
341
362
|
return;
|
|
342
|
-
for (const check of task
|
|
363
|
+
for (const check of requiredChecks(task))
|
|
343
364
|
if (!(await this.check(task, check.key, null)))
|
|
344
365
|
return;
|
|
366
|
+
// Item 8: a declared critic reviews the verified deliverables before completion.
|
|
367
|
+
if (task.completion_gate?.critic) {
|
|
368
|
+
const verdict = await this.verification.criticGateAsync(task);
|
|
369
|
+
if (verdict.state === 'pending')
|
|
370
|
+
return;
|
|
371
|
+
if (verdict.state === 'failed') {
|
|
372
|
+
this.stop(task, 'Critic review failed: ' + verdict.reason);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (verdict.state === 'changes_needed') {
|
|
376
|
+
const round = this.verification.critic.reopens(task);
|
|
377
|
+
if (round > CRITIC_REOPENS || Number(r.remaining_steps) < 1) {
|
|
378
|
+
this.stop(task, 'Critic requested changes');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (!(await this.worker(task)) || this.stopped)
|
|
382
|
+
return;
|
|
383
|
+
this.tasks.reopenForCritic(task.id, task.steps.at(-1).id, round, Number(r.auto_epoch), verdict.findings
|
|
384
|
+
.map((f) => `- ${f.path}:${f.line} (${f.severity}) ${f.message}`)
|
|
385
|
+
.join('\n'));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
345
389
|
await this.verification.finishAsync(task.id, 'auto-finish:' + task.id + ':' + task.plan_revision, task.revision, null, () => {
|
|
346
390
|
if (!this.budget.state(task.id).auto_enabled)
|
|
347
391
|
throw new ServiceError('task_authority', 'Progression paused before completion');
|
package/dist/src/task-policy.js
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { integer, object, ServiceError, string } from './protocol.js';
|
|
2
2
|
import { filePath } from './workspaces.js';
|
|
3
|
+
/** Every check completion requires: the acceptance checks, then each hook as hook-<key>. */
|
|
4
|
+
export function requiredChecks(policy) {
|
|
5
|
+
return [
|
|
6
|
+
...policy.acceptance,
|
|
7
|
+
...(policy.completion_gate?.hooks ?? []).map((h) => ({ ...h, key: 'hook-' + h.key })),
|
|
8
|
+
];
|
|
9
|
+
}
|
|
10
|
+
const check = (v, keys, what) => {
|
|
11
|
+
const c = object(v), key = string(c.key, what + ' key', 64);
|
|
12
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(key) || keys.has(key))
|
|
13
|
+
throw new ServiceError('invalid_plan', what + ' keys must be unique identifiers', 400);
|
|
14
|
+
keys.add(key);
|
|
15
|
+
return {
|
|
16
|
+
key,
|
|
17
|
+
command: string(c.command, what + ' command', 4096),
|
|
18
|
+
cwd: c.cwd === undefined || c.cwd === '.' ? '.' : filePath(c.cwd),
|
|
19
|
+
};
|
|
20
|
+
};
|
|
3
21
|
export const DEFAULT_TASK_LIMITS = {
|
|
4
22
|
requests: 64,
|
|
5
23
|
runtime_ms: 900000,
|
|
@@ -14,17 +32,7 @@ export function taskPolicy(value) {
|
|
|
14
32
|
if (!Array.isArray(raw) || raw.length > 8)
|
|
15
33
|
throw new ServiceError('invalid_plan', 'Supply at most eight acceptance checks', 400);
|
|
16
34
|
const keys = new Set();
|
|
17
|
-
const acceptance = raw.map((v) =>
|
|
18
|
-
const c = object(v), key = string(c.key, 'check key', 64);
|
|
19
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(key) || keys.has(key))
|
|
20
|
-
throw new ServiceError('invalid_plan', 'Acceptance keys must be unique identifiers', 400);
|
|
21
|
-
keys.add(key);
|
|
22
|
-
return {
|
|
23
|
-
key,
|
|
24
|
-
command: string(c.command, 'check command', 4096),
|
|
25
|
-
cwd: c.cwd === undefined || c.cwd === '.' ? '.' : filePath(c.cwd),
|
|
26
|
-
};
|
|
27
|
-
});
|
|
35
|
+
const acceptance = raw.map((v) => check(v, keys, 'Acceptance'));
|
|
28
36
|
const paths = value.deliverables ?? [];
|
|
29
37
|
if (!Array.isArray(paths) || paths.length > 32)
|
|
30
38
|
throw new ServiceError('invalid_plan', 'Supply at most 32 deliverable paths', 400);
|
|
@@ -66,5 +74,29 @@ export function taskPolicy(value) {
|
|
|
66
74
|
throw new ServiceError('invalid_budget', 'Invocation limit must be 1–64', 400);
|
|
67
75
|
limits.invocations = n;
|
|
68
76
|
}
|
|
69
|
-
|
|
77
|
+
let completion_gate;
|
|
78
|
+
if (value.completion_gate !== undefined) {
|
|
79
|
+
const g = object(value.completion_gate);
|
|
80
|
+
const hooks = g.hooks;
|
|
81
|
+
const c = g.critic;
|
|
82
|
+
const critic = c === null
|
|
83
|
+
? null
|
|
84
|
+
: (() => {
|
|
85
|
+
const v = object(c);
|
|
86
|
+
if (Object.keys(v).join(',') !== 'rubric')
|
|
87
|
+
throw new ServiceError('invalid_plan', 'A critic names only its rubric', 400);
|
|
88
|
+
return { rubric: string(v.rubric, 'critic rubric', 4096) };
|
|
89
|
+
})();
|
|
90
|
+
if (Object.keys(g).sort().join(',') !== 'critic,hooks' ||
|
|
91
|
+
!Array.isArray(hooks) ||
|
|
92
|
+
hooks.length > 8 ||
|
|
93
|
+
(!critic && hooks.length < 1))
|
|
94
|
+
throw new ServiceError('invalid_plan', 'A completion gate has a critic or 1–8 hooks, and at most 8 hooks', 400);
|
|
95
|
+
const hookKeys = new Set();
|
|
96
|
+
completion_gate = { critic, hooks: hooks.map((v) => check(v, hookKeys, 'Hook')) };
|
|
97
|
+
if (requiredChecks({ acceptance, completion_gate }).length !==
|
|
98
|
+
new Set(requiredChecks({ acceptance, completion_gate }).map((c) => c.key)).size)
|
|
99
|
+
throw new ServiceError('invalid_plan', 'A hook collides with an acceptance check', 400);
|
|
100
|
+
}
|
|
101
|
+
return { acceptance, deliverables, limits, ...(completion_gate ? { completion_gate } : {}) };
|
|
70
102
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requiredChecks } from './task-policy.js';
|
|
1
2
|
import { StationTaskVerification } from './station-task-verification.js';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { commandTerminal } from './coding.js';
|
|
@@ -39,7 +40,7 @@ export class TaskVerification {
|
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
assertIdle(task, stationInspection) {
|
|
42
|
-
if (this.budget.active(task) > 0 ||
|
|
43
|
+
if (this.budget.active(task, { verification: true }) > 0 ||
|
|
43
44
|
this.store.db
|
|
44
45
|
.prepare("SELECT 1 FROM compactions WHERE session_id=? AND state='running'")
|
|
45
46
|
.get(task.session_id))
|
|
@@ -105,7 +106,7 @@ export class TaskVerification {
|
|
|
105
106
|
this.assertIdle(task);
|
|
106
107
|
if (!this.station)
|
|
107
108
|
this.coding.assertSafe(task.session_id);
|
|
108
|
-
if (!task.
|
|
109
|
+
if (!requiredChecks(task).some((c) => c.key === key))
|
|
109
110
|
throw new ServiceError('invalid_check', 'Check is not in this task plan');
|
|
110
111
|
if (step &&
|
|
111
112
|
!task.steps.some((s) => s.id === step && s.state === 'awaiting_review' && s.checks?.includes(key)))
|
|
@@ -147,7 +148,7 @@ export class TaskVerification {
|
|
|
147
148
|
this.station.dispatch(id);
|
|
148
149
|
return;
|
|
149
150
|
}
|
|
150
|
-
const task = this.tasks.get(receipt.task_id), check = task.
|
|
151
|
+
const task = this.tasks.get(receipt.task_id), check = requiredChecks(task).find((c) => c.key === receipt.check_key);
|
|
151
152
|
try {
|
|
152
153
|
const existing = this.store.db
|
|
153
154
|
.prepare('SELECT id FROM command_runs WHERE session_id=? AND step_key=?')
|
|
@@ -383,6 +384,26 @@ export class TaskVerification {
|
|
|
383
384
|
throw new ServiceError('verification_required', 'Protected station completion requires awaited verification');
|
|
384
385
|
return this.finishRecorded(id, request, expected, exception, authorize);
|
|
385
386
|
}
|
|
387
|
+
/** Item 8: the completion critic, when the service has one. */
|
|
388
|
+
critic;
|
|
389
|
+
/** The critic's verdict on the current fresh verified evidence, starting it the first time.
|
|
390
|
+
* Reads the evidence outside the finishing transaction, so starting the reviewer can never be
|
|
391
|
+
* rolled back with a refused finish; the finish itself re-checks the verdict against its own
|
|
392
|
+
* evidence. */
|
|
393
|
+
async criticGateAsync(task) {
|
|
394
|
+
if (!task.completion_gate?.critic)
|
|
395
|
+
return { state: 'pass' };
|
|
396
|
+
if (!this.critic || !this.station)
|
|
397
|
+
return { state: 'failed', reason: 'A critic needs the station verification boundary' };
|
|
398
|
+
const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
|
|
399
|
+
if (checks.some((c) => !c || c.state !== 'passed'))
|
|
400
|
+
return { state: 'pending' };
|
|
401
|
+
const fresh = await this.station.withFreshMany(checks.map((c) => c.id), task.deliverables, (evidence, deliverables) => ({ evidence, deliverables }));
|
|
402
|
+
return this.critic.gate(task, digest(fresh.evidence), fresh.deliverables.map(({ path, content }) => ({
|
|
403
|
+
path,
|
|
404
|
+
content,
|
|
405
|
+
})));
|
|
406
|
+
}
|
|
386
407
|
async finishAsync(id, request, expected, exception, authorize) {
|
|
387
408
|
if (!this.station)
|
|
388
409
|
return this.finish(id, request, expected, exception, authorize);
|
|
@@ -401,7 +422,7 @@ export class TaskVerification {
|
|
|
401
422
|
throw new ServiceError('verification_required', 'Strict station work requires verified completion without exceptions');
|
|
402
423
|
if (!task.acceptance.length || !task.deliverables.length)
|
|
403
424
|
throw new ServiceError('verification_required', 'Configured checks and deliverables required');
|
|
404
|
-
const checks = task.
|
|
425
|
+
const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
|
|
405
426
|
if (checks.some((c) => !c || c.state !== 'passed'))
|
|
406
427
|
throw new ServiceError('verification_required', 'Passed configured checks required');
|
|
407
428
|
return this.station.withFreshMany(checks.map((c) => c.id), task.deliverables, (evidence, deliverables) => this.finishRecorded(id, request, expected, null, authorize, { evidence, deliverables }));
|
|
@@ -415,7 +436,7 @@ export class TaskVerification {
|
|
|
415
436
|
if (view.completion?.state === 'succeeded') {
|
|
416
437
|
try {
|
|
417
438
|
const evidence = JSON.parse(this.artifacts.read(String(view.completion.artifact_id)));
|
|
418
|
-
const checks = task.
|
|
439
|
+
const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
|
|
419
440
|
if (checks.some((c) => !c || c.state !== 'passed' || !evidence.checks.includes(c.id)))
|
|
420
441
|
throw Error('Changed checks');
|
|
421
442
|
view.completion.fresh = await this.station.withFreshMany(checks.map((c) => c.id), task.deliverables, (fresh) => view.completion.plan_revision === this.tasks.get(task.id).plan_revision &&
|
|
@@ -441,7 +462,7 @@ export class TaskVerification {
|
|
|
441
462
|
this.assertIdle(task);
|
|
442
463
|
if (task.steps.some((s) => s.state !== 'completed'))
|
|
443
464
|
throw new ServiceError('verification_required', 'Every step must be reviewed or verified');
|
|
444
|
-
const checks = task.
|
|
465
|
+
const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
|
|
445
466
|
const missing = [];
|
|
446
467
|
if (!task.acceptance.length)
|
|
447
468
|
missing.push('No configured acceptance checks');
|
|
@@ -452,13 +473,21 @@ export class TaskVerification {
|
|
|
452
473
|
(protectedResult
|
|
453
474
|
? !protectedResult.evidence.some((e) => e.check_id === checks[n].id)
|
|
454
475
|
: !this.fresh(checks[n])))
|
|
455
|
-
missing.push('Fresh check required: ' + task
|
|
476
|
+
missing.push('Fresh check required: ' + requiredChecks(task)[n].key);
|
|
456
477
|
let workspace = null, deliverables = [], diff = null;
|
|
457
478
|
try {
|
|
458
479
|
if (protectedResult) {
|
|
459
480
|
const e = protectedResult.evidence[0];
|
|
460
481
|
workspace = { id: 'station:' + e.root_digest, revision: 0, digest: e.tree_digest };
|
|
461
|
-
|
|
482
|
+
// Keep each deliverable's verified bytes as a session artifact, committed with the
|
|
483
|
+
// completion, so publication reads the verified revision and never the live workspace.
|
|
484
|
+
deliverables = protectedResult.deliverables.map(({ path, sha256, content }) => {
|
|
485
|
+
if (typeof content !== 'string')
|
|
486
|
+
throw new ServiceError('verification_required', 'Deliverable bytes unavailable');
|
|
487
|
+
const artifact = this.artifacts.prepare(task.session_id, content);
|
|
488
|
+
artifact.commit();
|
|
489
|
+
return { path, sha256, artifact_id: artifact.id };
|
|
490
|
+
});
|
|
462
491
|
diff = {
|
|
463
492
|
backend: 'linux-systemd-station-v1',
|
|
464
493
|
root_digest: e.root_digest,
|
|
@@ -481,6 +510,10 @@ export class TaskVerification {
|
|
|
481
510
|
catch (error) {
|
|
482
511
|
missing.push('Workspace verification unavailable: ' + error.message);
|
|
483
512
|
}
|
|
513
|
+
// Item 8: a declared critic must have passed this exact verified evidence.
|
|
514
|
+
if (task.completion_gate?.critic &&
|
|
515
|
+
!(protectedResult && this.critic?.passed(task, digest(protectedResult.evidence))))
|
|
516
|
+
missing.push('Critic review required');
|
|
484
517
|
if (missing.length && !exception)
|
|
485
518
|
throw new ServiceError('verification_required', missing.join('; '));
|
|
486
519
|
if (exception) {
|
|
@@ -544,7 +577,7 @@ export class TaskVerification {
|
|
|
544
577
|
if (completion.state === 'succeeded')
|
|
545
578
|
fresh =
|
|
546
579
|
fresh &&
|
|
547
|
-
task.
|
|
580
|
+
requiredChecks(task).every((check) => {
|
|
548
581
|
const latest = this.latest(task, check.key, null);
|
|
549
582
|
return !!latest && evidence.checks.includes(latest.id) && this.fresh(latest);
|
|
550
583
|
});
|
package/dist/src/task-work.js
CHANGED
|
@@ -34,6 +34,52 @@ export class TaskWork {
|
|
|
34
34
|
.prepare('UPDATE task_runtime SET remaining_steps=remaining_steps-1 WHERE task_id=?')
|
|
35
35
|
.run(task.id);
|
|
36
36
|
}
|
|
37
|
+
/** Item 8: re-run the last completed step with the critic's findings added to its instruction,
|
|
38
|
+
* under its own re-prepared progression grant and a request of its own. */
|
|
39
|
+
reopenForCritic(taskId, stepId, round, epoch, findings) {
|
|
40
|
+
const task = this.get(taskId);
|
|
41
|
+
const step = task.steps.find((s) => s.id === stepId);
|
|
42
|
+
const grant = this.store.db
|
|
43
|
+
.prepare("SELECT id FROM task_grants WHERE task_id=? AND step_id=? AND plan_revision=? AND epoch=? AND state='accepted'")
|
|
44
|
+
.get(task.id, stepId, task.plan_revision, epoch);
|
|
45
|
+
if (!grant || !step || step.state !== 'completed' || task.steps.at(-1)?.id !== stepId)
|
|
46
|
+
throw new ServiceError('task_authority', 'Only the completed last step can be reopened');
|
|
47
|
+
this.store.transaction(() => {
|
|
48
|
+
this.store.db.prepare("UPDATE task_steps SET state='pending' WHERE id=?").run(stepId);
|
|
49
|
+
this.store.db.prepare("UPDATE task_grants SET state='prepared' WHERE id=?").run(grant.id);
|
|
50
|
+
this.store.event(task.session_id, 'work_task.step_reopened', {
|
|
51
|
+
task_id: task.id,
|
|
52
|
+
step_id: stepId,
|
|
53
|
+
grant_id: grant.id,
|
|
54
|
+
round,
|
|
55
|
+
reason: 'Critic requested changes',
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
return this.run(task.id, stepId, 'task-grant:' + grant.id + ':critic-reopen:' + round, this.get(task.id).revision, step.instruction +
|
|
59
|
+
'\n\nBefore this work is accepted, a reviewer asked for these changes:\n' +
|
|
60
|
+
findings, null, null, grant.id);
|
|
61
|
+
}
|
|
62
|
+
/** Re-run a step the provider rate-limited, as a fresh attempt under its own progression grant
|
|
63
|
+
* (re-prepared, with a request of its own, so it is never a replay of the refused attempt). */
|
|
64
|
+
retryStep(taskId, stepId, n, epoch) {
|
|
65
|
+
const task = this.get(taskId);
|
|
66
|
+
const grant = this.store.db
|
|
67
|
+
.prepare("SELECT id FROM task_grants WHERE task_id=? AND step_id=? AND plan_revision=? AND epoch=? AND state='accepted'")
|
|
68
|
+
.get(task.id, stepId, task.plan_revision, epoch);
|
|
69
|
+
if (!grant || task.steps.find((s) => s.id === stepId)?.state !== 'failed')
|
|
70
|
+
throw new ServiceError('task_authority', 'No settled grant for this rate-limited step');
|
|
71
|
+
this.store.transaction(() => {
|
|
72
|
+
this.store.db.prepare("UPDATE task_grants SET state='prepared' WHERE id=?").run(grant.id);
|
|
73
|
+
this.store.event(task.session_id, 'work_task.step_retried', {
|
|
74
|
+
task_id: task.id,
|
|
75
|
+
step_id: stepId,
|
|
76
|
+
grant_id: grant.id,
|
|
77
|
+
retry: n,
|
|
78
|
+
reason: 'Provider rate limit',
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
return this.run(task.id, stepId, 'task-grant:' + grant.id + ':rate-limit-retry:' + n, task.revision, null, null, null, grant.id);
|
|
82
|
+
}
|
|
37
83
|
advance(grantId) {
|
|
38
84
|
const grant = this.store.db.prepare('SELECT * FROM task_grants WHERE id=?').get(grantId);
|
|
39
85
|
if (!grant)
|
|
@@ -439,6 +485,7 @@ export class TaskWork {
|
|
|
439
485
|
objective: task.objective,
|
|
440
486
|
constraints: task.constraints,
|
|
441
487
|
acceptance: task.acceptance,
|
|
488
|
+
...(task.completion_gate ? { completion_gate: task.completion_gate } : {}),
|
|
442
489
|
deliverables: task.deliverables,
|
|
443
490
|
limits: task.limits,
|
|
444
491
|
active_step_id: task.steps.find((s) => s.state === 'running')?.id ?? null,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
export function terminalState(snapshot) {
|
|
3
|
+
if (!snapshot)
|
|
4
|
+
return 'idle';
|
|
5
|
+
if ((snapshot.approvals ?? []).some((a) => a.state === 'pending'))
|
|
6
|
+
return 'blocked';
|
|
7
|
+
if ((snapshot.attempts ?? []).some((a) => ['accepted', 'dispatching', 'running'].includes(a.state)))
|
|
8
|
+
return 'working';
|
|
9
|
+
return 'idle';
|
|
10
|
+
}
|
|
11
|
+
export function titleFor(state, name) {
|
|
12
|
+
// eslint-disable-next-line no-control-regex
|
|
13
|
+
const clean = name
|
|
14
|
+
?.replace(/[\x00-\x1f\x7f-\x9f]/g, '')
|
|
15
|
+
.trim()
|
|
16
|
+
.slice(0, 32);
|
|
17
|
+
if (!clean)
|
|
18
|
+
return 'Salaam';
|
|
19
|
+
const words = state === 'blocked' ? 'needs you' : state === 'working' ? 'working' : 'idle';
|
|
20
|
+
return `Salaam · ${clean} · ${words}`;
|
|
21
|
+
}
|
|
22
|
+
export function statusEnabled(env = process.env) {
|
|
23
|
+
return env.SALAAM_TERMINAL_STATUS !== 'off';
|
|
24
|
+
}
|
|
25
|
+
const run = (bin, args, done) => {
|
|
26
|
+
// Short timeout, failures ignored: Herdr must never slow Salaam down.
|
|
27
|
+
execFile(bin, args, { timeout: 2000, windowsHide: true }, () => done());
|
|
28
|
+
};
|
|
29
|
+
/** Reports to Herdr only inside a Herdr pane; one report in flight, newest state wins. */
|
|
30
|
+
export class HerdrReporter {
|
|
31
|
+
env;
|
|
32
|
+
exec;
|
|
33
|
+
now;
|
|
34
|
+
enabled;
|
|
35
|
+
seq = 0;
|
|
36
|
+
busy = false;
|
|
37
|
+
queued;
|
|
38
|
+
last;
|
|
39
|
+
constructor(env = process.env, exec = run, now = Date.now) {
|
|
40
|
+
this.env = env;
|
|
41
|
+
this.exec = exec;
|
|
42
|
+
this.now = now;
|
|
43
|
+
this.enabled = !!(env.HERDR_ENV === '1' && env.HERDR_PANE_ID && env.HERDR_BIN_PATH);
|
|
44
|
+
}
|
|
45
|
+
nextSeq() {
|
|
46
|
+
this.seq = Math.max(this.seq + 1, this.now());
|
|
47
|
+
return String(this.seq);
|
|
48
|
+
}
|
|
49
|
+
head(verb) {
|
|
50
|
+
return ['pane', verb, this.env.HERDR_PANE_ID, '--source', 'salaam', '--agent', 'salaam'];
|
|
51
|
+
}
|
|
52
|
+
report(state, session) {
|
|
53
|
+
if (!this.enabled)
|
|
54
|
+
return;
|
|
55
|
+
const key = state + ' ' + (session ?? '');
|
|
56
|
+
if (key === this.last)
|
|
57
|
+
return;
|
|
58
|
+
this.last = key;
|
|
59
|
+
this.queued = [
|
|
60
|
+
...this.head('report-agent'),
|
|
61
|
+
'--state',
|
|
62
|
+
state,
|
|
63
|
+
'--seq',
|
|
64
|
+
'',
|
|
65
|
+
...(state === 'blocked' ? ['--message', 'Waiting for your approval'] : []),
|
|
66
|
+
...(session ? ['--agent-session-id', session, '--', 'salaam', '--session', session] : []),
|
|
67
|
+
];
|
|
68
|
+
this.flush();
|
|
69
|
+
}
|
|
70
|
+
release() {
|
|
71
|
+
if (!this.enabled)
|
|
72
|
+
return;
|
|
73
|
+
this.queued = undefined;
|
|
74
|
+
this.exec(this.env.HERDR_BIN_PATH, [...this.head('release-agent'), '--seq', this.nextSeq()], () => { });
|
|
75
|
+
}
|
|
76
|
+
flush() {
|
|
77
|
+
if (this.busy || !this.queued)
|
|
78
|
+
return;
|
|
79
|
+
const args = this.queued;
|
|
80
|
+
this.queued = undefined;
|
|
81
|
+
args[args.indexOf('--seq') + 1] = this.nextSeq();
|
|
82
|
+
this.busy = true;
|
|
83
|
+
this.exec(this.env.HERDR_BIN_PATH, args, () => {
|
|
84
|
+
this.busy = false;
|
|
85
|
+
this.flush();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|