@evoclock/pi-agentic-driver 0.4.3 → 0.6.0
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/PROVENANCE.md +5 -0
- package/README.md +336 -190
- package/config/microvm-target.v1.example.json +5 -0
- package/config/microvm-target.v1.json +5 -0
- package/extensions/herdr-dispatch.ts +27 -0
- package/extensions/linux-microvm.ts +11 -7
- package/package.json +31 -2
- package/scripts/enforcement/guest_containment_taxonomy.v1.json +26 -0
- package/scripts/enforcement/herdr_async_dispatch_pi.js +402 -0
- package/scripts/enforcement/linux_microvm_cutover_pi.js +512 -56
- package/scripts/enforcement/linux_microvm_remote_fixture.sh +2418 -19
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "One decision: where the microVM runs. The target is configured through the cutover tool's native confirmation (the optional target parameter: user@host, ip, or local) and saved into the resolved Pi config directory. Arch, libvirt URI, and kernel are auto-discovered from the target at probe time; only honest safety checks are enforced (/dev/kvm accessible, system-level libvirt driver, qemu-system binary matching the discovered arch). Note: the shipped fixture guest is built for x86_64, so the full proof currently requires an x86_64 KVM host.",
|
|
3
|
+
"schema": "agentic-driver.microvm-target.v1",
|
|
4
|
+
"sshTarget": "REPLACE-WITH-user@host-or-ip"
|
|
5
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "One decision: where the microVM runs. The target is configured through the cutover tool's native confirmation (the optional target parameter: user@host, ip, or local) and saved into the resolved Pi config directory. Arch, libvirt URI, and kernel are auto-discovered from the target at probe time; only honest safety checks are enforced (/dev/kvm accessible, system-level libvirt driver, qemu-system binary matching the discovered arch). Note: the shipped fixture guest is built for x86_64, so the full proof currently requires an x86_64 KVM host.",
|
|
3
|
+
"schema": "agentic-driver.microvm-target.v1",
|
|
4
|
+
"sshTarget": "REPLACE-WITH-user@host-or-ip"
|
|
5
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
export default async function herdrDispatchPi(pi) {
|
|
5
|
+
const dispatch = await import(new URL("../scripts/enforcement/herdr_async_dispatch_pi.js", import.meta.url).href);
|
|
6
|
+
const lifecycle = await import(new URL("../scripts/enforcement/herdr_lifecycle_pi.js", import.meta.url).href);
|
|
7
|
+
|
|
8
|
+
// Production replacement seam: an unresponsive-session handoff spawns through the
|
|
9
|
+
// existing guarded herdr-lifecycle boundary only — executeHerdrSpawnWorker
|
|
10
|
+
// performs native confirmation, trusted-repository resolution, installed
|
|
11
|
+
// model-roll resolution, fixed argv, and shell:false. No raw pane/agent
|
|
12
|
+
// management is added; the receipt is observed state, never authority.
|
|
13
|
+
const spawnReplacement = ({ role, repository, model, context, signal }) =>
|
|
14
|
+
lifecycle.executeHerdrSpawnWorker(
|
|
15
|
+
{
|
|
16
|
+
placement: "tab",
|
|
17
|
+
role,
|
|
18
|
+
model,
|
|
19
|
+
repository,
|
|
20
|
+
},
|
|
21
|
+
context,
|
|
22
|
+
{},
|
|
23
|
+
signal,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return dispatch.registerWorkerDispatchInterface(pi, { spawnReplacement });
|
|
27
|
+
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import { registerLinuxMicroVMCutoverInterface, registerIsolationSwitchCommands, createIsolationSwitch } from "../scripts/enforcement/linux_microvm_cutover_pi.js";
|
|
2
5
|
|
|
3
6
|
export default function registerLinuxMicroVMCutover(pi: any) {
|
|
4
|
-
// Registration is limited to the activation-deferred cutover interface
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// Registration is limited to the activation-deferred cutover interface and
|
|
8
|
+
// the session-scoped isolation switch commands. The legacy work-mode
|
|
9
|
+
// lifecycle is not registered: agentic_work_mode is not exposed.
|
|
10
|
+
// The isolation switch state is created per registration, so each fresh
|
|
11
|
+
// extension registration (session) starts disabled.
|
|
12
|
+
const isolationSwitch = registerIsolationSwitchCommands(pi, { isolationSwitch: createIsolationSwitch() });
|
|
13
|
+
registerLinuxMicroVMCutoverInterface(pi, { isolationSwitch });
|
|
10
14
|
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evoclock/pi-agentic-driver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Guardrail extensions for Agentic Driver: advisory review, bounded Herdr communication, and guarded worker lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"pi-package",
|
|
9
9
|
"pi-extension",
|
|
10
|
-
"
|
|
10
|
+
"pi",
|
|
11
|
+
"pi-coding-agent",
|
|
12
|
+
"agentic-driver",
|
|
13
|
+
"agentic-workflows",
|
|
14
|
+
"multi-agent",
|
|
15
|
+
"sub-agents",
|
|
16
|
+
"agent-coordination",
|
|
17
|
+
"worker-dispatch",
|
|
18
|
+
"continuous-agents",
|
|
19
|
+
"task-orchestration",
|
|
20
|
+
"herdr",
|
|
21
|
+
"code-review",
|
|
22
|
+
"writing-review",
|
|
23
|
+
"ai-dr",
|
|
24
|
+
"microvm",
|
|
25
|
+
"isolation",
|
|
26
|
+
"containers",
|
|
27
|
+
"git-workflow",
|
|
28
|
+
"git-safety",
|
|
29
|
+
"context-management",
|
|
30
|
+
"compaction",
|
|
31
|
+
"checkpoint-recovery",
|
|
32
|
+
"developer-tools",
|
|
33
|
+
"automation",
|
|
34
|
+
"governance"
|
|
11
35
|
],
|
|
12
36
|
"files": [
|
|
13
37
|
"extensions/code-phage.js",
|
|
14
38
|
"extensions/herdr-communication.ts",
|
|
39
|
+
"extensions/herdr-dispatch.ts",
|
|
15
40
|
"lib/adapters/diff-scope.mjs",
|
|
16
41
|
"lib/adapters/evidence.mjs",
|
|
17
42
|
"lib/adapters/narrative.mjs",
|
|
@@ -21,14 +46,18 @@
|
|
|
21
46
|
"lib/python_ast_metrics.py",
|
|
22
47
|
"lib/typescript_ast_metrics.mjs",
|
|
23
48
|
"scripts/enforcement/herdr_communication_pi.js",
|
|
49
|
+
"scripts/enforcement/herdr_async_dispatch_pi.js",
|
|
24
50
|
"README.md",
|
|
25
51
|
"LICENSE",
|
|
26
52
|
"extensions/herdr-lifecycle.ts",
|
|
27
53
|
"scripts/enforcement/herdr_lifecycle_pi.js",
|
|
54
|
+
"config/microvm-target.v1.example.json",
|
|
55
|
+
"config/microvm-target.v1.json",
|
|
28
56
|
"config/herdr-worker-repositories.v1.json",
|
|
29
57
|
"extensions/linux-microvm.ts",
|
|
30
58
|
"scripts/enforcement/linux_microvm_cutover_pi.js",
|
|
31
59
|
"scripts/enforcement/native_tui_context.js",
|
|
60
|
+
"scripts/enforcement/guest_containment_taxonomy.v1.json",
|
|
32
61
|
"scripts/enforcement/linux_microvm_remote_fixture.sh",
|
|
33
62
|
"PROVENANCE.md",
|
|
34
63
|
"extensions/aidr.ts",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "guest-containment-taxonomy.v1",
|
|
3
|
+
"description": "Deny-by-default rule taxonomy for the guest containment monitor (design GUEST_CONTAINMENT_DESIGN.md section 2). One rule per line; IDs are never reused; new rules append. disposition records whether a live detector exists in the fixture (design sections 1.3, 3) or the rule is armed by the taxonomy only.",
|
|
4
|
+
"rules": [
|
|
5
|
+
{"id":"GC-PKG-001","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"package-manager-install: invocation of npm install, npm ci, pip install, yarn add, pnpm add, gem install, cargo install, apk add, apt install","evidence":"METR autonomous-replication evaluation work (arXiv:2403.13793); HF incident dependency-install persistence step","since":"v1","disposition":"detector:shim-argv"},
|
|
6
|
+
{"id":"GC-PKG-002","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"registry-write-command: npm publish, npm token, .npmrc writes, pip config, keyring writes","evidence":"HF incident: agent attempted package publication","since":"v1","disposition":"detector:shim-argv"},
|
|
7
|
+
{"id":"GC-PKG-003","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"package-metadata-tamper: writes to package.json, package-lock.json, pyproject.toml, requirements*.txt outside the job scratch space","evidence":"METR evaluation logs: dependency-manifest tampering as persistence step","since":"v1","disposition":"detector:fs-path+shim-argv"},
|
|
8
|
+
{"id":"GC-FSW-001","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"write-outside-allowlist: create/append/truncate on any path outside /tmp/session, detected by inotifyd or find -newer sweeps","evidence":"METR evaluation logs: out-of-bounds file writes during evaluation runs","since":"v1","disposition":"detector:fs-watcher"},
|
|
9
|
+
{"id":"GC-FSW-002","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"self-code-modification: writes under the agent job install/execution directories including the shim tree /shims/","evidence":"HF incident: agent rewrote the repository it was working in; monitor self-rewrite defense","since":"v1","disposition":"detector:fs-watcher"},
|
|
10
|
+
{"id":"GC-FSW-003","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"file-growth-anomaly: unexpected growth in cache/package directories (~/.npm, ~/.cache/pip, /var/cache/*) beyond the size-delta threshold","evidence":"METR evaluation logs: bulk cache staging of payloads","since":"v1","disposition":"detector:fs-sweep size-delta sampling (gc_cache_growth_sample; aggregate ELEVATED, GC_CACHE_GROWTH_KIB per sweep)"},
|
|
11
|
+
{"id":"GC-SHR-001","class":"GC-SHR","severity":"HIGH","action":"deny","match":"shared-cache-write: writes to paths shared with other agents or the coordinator; with host-share absent any such attempt is a deny","evidence":"receipt proves host-share=absent; writes would indicate boundary violation","since":"v1","disposition":"detector:fs-path; share paths auto-arm via gc_share_mounted (/proc/mounts); shared-cache roots classify as SHR when unarmed"},
|
|
12
|
+
{"id":"GC-SHR-002","class":"GC-SHR","severity":"HIGH","action":"deny","match":"cross-agent-channel: creation of or writes to inter-agent coordination paths (maildirs, task-queue files, agent state files)","evidence":"HF incident multi-agent escalation; METR coordination/sabotage threat model","since":"v1","disposition":"detector:fs-path+shim-argv heuristics (Maildir, task-queue, agent-channel, coordination paths)"},
|
|
13
|
+
{"id":"GC-NET-001","class":"GC-NET","severity":"HIGH","action":"deny","match":"socket-open: any entry in /proc/net/tcp, /proc/net/tcp6, /proc/net/udp at any sweep","evidence":"fixture boots with no network device; any socket is exfiltration-capable","since":"v1","disposition":"detector:net-proc-sweep"},
|
|
14
|
+
{"id":"GC-NET-002","class":"GC-NET","severity":"HIGH","action":"deny","match":"network-tool-invocation: shim-detects wget, curl, nc, ssh, telnet, ping regardless of arguments","evidence":"METR self-replication exfiltration patterns; HF incident remote control","since":"v1","disposition":"detector:shim-argv"},
|
|
15
|
+
{"id":"GC-CRED-001","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"credential-path-read: opens of ~/.ssh/, ~/.aws/, ~/.netrc, ~/.git-credentials, token files","evidence":"credential theft is step one in every reported agent-escape chain","since":"v1","disposition":"detector:shim-argv"},
|
|
16
|
+
{"id":"GC-CRED-002","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"env-secret-probing: attempts to dump or scan the environment for secret-shaped keys","evidence":"METR evaluation logs; HF incident postmortem credential harvest attempts","since":"v1","disposition":"detector:shim-argv"},
|
|
17
|
+
{"id":"GC-LOG-001","class":"GC-LOG","severity":"CRITICAL","action":"deny","match":"transcript-write: writes to the console transcript, the monitor log, or the receipt channel by any path other than the supervisor","evidence":"log is opened append-only by the supervisor; other writers are evidence tampering","since":"v1","disposition":"detector:fs-path+shim-argv (transcript/receipt-channel paths); the in-guest supervisor never writes these, so any event is a non-supervisor write"},
|
|
18
|
+
{"id":"GC-LOG-002","class":"GC-LOG","severity":"CRITICAL","action":"deny","match":"log-truncate-or-rename: >, rm, mv, truncate, sed -i targeting monitor/agent logs","evidence":"METR report: models attempting to edit their own logs","since":"v1","disposition":"detector:shim-argv"},
|
|
19
|
+
{"id":"GC-TOOL-001","class":"GC-TOOL","severity":"CRITICAL","action":"deny","match":"receipt-forgery: job printing AGENTIC_MICROVM_PROBE:* or AGENTIC_MICROVM_RECEIPT:* lines","evidence":"host closed-receipt parsing rejects forged output; guest-side denial closes the loop","since":"v1","disposition":"detector:shim-argv"},
|
|
20
|
+
{"id":"GC-TOOL-002","class":"GC-TOOL","severity":"CRITICAL","action":"deny","match":"marker-injection: writes to /dev/console, /dev/ttyS0, or other devtmpfs write targets that could inject marker text","evidence":"METR tool-call spoofing family; serial stream integrity","since":"v1","disposition":"detector:shim-argv+fs-path; known gap: char-device writes may not update mtime for the sweep"},
|
|
21
|
+
{"id":"GC-PROBE-001","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"containment-introspection: reads or probes of /gc/** (core.sh, taxonomy, state), /shims/**, or session-dir metadata outside sanctioned /tmp/session writes","evidence":"METR report: agents research the monitoring environment before acting; HF incident agents probed sandbox infrastructure. Individually ambiguous (a job may stat its own directories), so aggregate ELEVATED rather than immediate-kill","since":"v1","disposition":"detector:fs-path+shim-argv"},
|
|
22
|
+
{"id":"GC-PROBE-002","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"monitor-enumeration: ps/proc access patterns targeting watcher discovery (inspection looking for monitor PIDs, inotifyd, or the shim layer), shim/proc-watcher detected","evidence":"Design section 9.1 adversarial pass: monitor enumeration precedes timing and evasion attempts; aggregate ELEVATED avoids false-positive kills on process inspection","since":"v1","disposition":"detector:shim-argv"},
|
|
23
|
+
{"id":"GC-PROBE-003","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"concealment-index anomaly: aggregated probe-frequency signal (GC-PROBE events plus weighted unknown-class denials) surfaced as the receipt concealmentIndex","evidence":"Timing probes and repeated no-op shim invocations to time detection are aggregate signals, not single-action violations; see design appendix formula","since":"v1","disposition":"detector:evidence-aggregate"}
|
|
24
|
+
],
|
|
25
|
+
"residual": {"class":"unknown","tier":"ANOMALY","action":"deny","note":"everything not allowlisted and not classified above; accumulates toward the ANOMALY threshold"}
|
|
26
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
// Async continuous worker dispatch and pulse. One bounded step on top of the
|
|
5
|
+
// shipped herdr-communication interface: a worker progresses through an
|
|
6
|
+
// existing task sequence (observed, never created) and emits one collated
|
|
7
|
+
// marked report. Continuous mode is the default; interactive turn-by-turn is
|
|
8
|
+
// explicit opt-in. Journeys are bounded by step count, never by a wall-clock
|
|
9
|
+
// timeout; cancellation and terminal failure are explicit and there are no
|
|
10
|
+
// invisible retries or resends.
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
executeHerdrCommunication,
|
|
14
|
+
HERDR_REPORT_MARKERS,
|
|
15
|
+
HERDR_COMMUNICATION_SCHEMA,
|
|
16
|
+
HERDR_COMMUNICATION_ACTIONS,
|
|
17
|
+
} from "./herdr_communication_pi.js";
|
|
18
|
+
import { isNativeTuiContext } from "./native_tui_context.js";
|
|
19
|
+
|
|
20
|
+
export const WORKER_DISPATCH_TOOL = "agentic_worker_dispatch";
|
|
21
|
+
export const WORKER_DISPATCH_SCHEMA = "agentic-driver.worker-dispatch.v1";
|
|
22
|
+
export const WORKER_DISPATCH_MODES = Object.freeze(["continuous", "turn-by-turn"]);
|
|
23
|
+
export const DEFAULT_MODE = "continuous";
|
|
24
|
+
const DEFAULT_JOURNEY_STEPS = 50;
|
|
25
|
+
const MAX_JOURNEY_STEPS = 200;
|
|
26
|
+
const MAX_REPORT_BYTES = 32 * 1024;
|
|
27
|
+
const REGISTRATIONS = new WeakSet();
|
|
28
|
+
|
|
29
|
+
// Terminal journey states. Only `cancelled` and `failed` are failures; every
|
|
30
|
+
// other terminal state is an observed outcome, and no state is retried.
|
|
31
|
+
export const WORKER_DISPATCH_TERMINAL_STATES = Object.freeze([
|
|
32
|
+
"completed", "exhausted", "role-blocked", "cancelled", "failed", "waiting-approval", "worker-unresponsive",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export const WORKER_DISPATCH_PARAMETERS = Object.freeze({
|
|
36
|
+
type: "object",
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
properties: {
|
|
39
|
+
action: { type: "string", enum: ["dispatch", "pulse"] },
|
|
40
|
+
role: { type: "string", pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", maxLength: 64 },
|
|
41
|
+
mode: { type: "string", enum: WORKER_DISPATCH_MODES },
|
|
42
|
+
maxSteps: { type: "integer", minimum: 1, maximum: MAX_JOURNEY_STEPS },
|
|
43
|
+
stepPrompt: { type: "string", minLength: 1, maxLength: 8192 },
|
|
44
|
+
model: { type: "string", pattern: "^[a-z0-9][a-z0-9._-]{0,63}(?:\\/[a-z0-9][a-z0-9._-]{0,127})*$", maxLength: 192 },
|
|
45
|
+
},
|
|
46
|
+
required: ["action", "role"],
|
|
47
|
+
allOf: [
|
|
48
|
+
{
|
|
49
|
+
if: { properties: { action: { const: "dispatch" } }, required: ["action"] },
|
|
50
|
+
then: { required: ["stepPrompt"] },
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
class WorkerDispatchError extends Error {
|
|
56
|
+
constructor(code, message, status = "blocked") {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = "WorkerDispatchError";
|
|
59
|
+
this.code = code;
|
|
60
|
+
this.status = status;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function dispatchError(code, message, status = "blocked") {
|
|
65
|
+
return new WorkerDispatchError(code, message, status);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function result(details) {
|
|
69
|
+
return { ok: details.ok === true, ...details };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function failure(action, error) {
|
|
73
|
+
const code = error?.code || "dispatch-failed";
|
|
74
|
+
return {
|
|
75
|
+
schema: WORKER_DISPATCH_SCHEMA,
|
|
76
|
+
ok: false,
|
|
77
|
+
action,
|
|
78
|
+
status: error?.status || "blocked",
|
|
79
|
+
code,
|
|
80
|
+
error: String(error?.message || error).slice(0, 512),
|
|
81
|
+
nonAuthorizing: true,
|
|
82
|
+
persisted: false,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Worker pulse: liveness, current state, and dispatch eligibility, observed
|
|
87
|
+
// through the existing non-authorizing get seam. Grants no authority.
|
|
88
|
+
export async function workerPulse(role, context, options = {}, signal) {
|
|
89
|
+
if (typeof role !== "string" || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(role) || role.length > 64) {
|
|
90
|
+
throw dispatchError("role-invalid", "worker pulse requires a valid non-coordinator role name", "denied");
|
|
91
|
+
}
|
|
92
|
+
const observation = await executeHerdrCommunication(
|
|
93
|
+
{ action: "get", role },
|
|
94
|
+
context,
|
|
95
|
+
options.communication ?? options,
|
|
96
|
+
signal,
|
|
97
|
+
);
|
|
98
|
+
if (observation.ok !== true) {
|
|
99
|
+
throw dispatchError(observation.code || "pulse-failed",
|
|
100
|
+
observation.reason || observation.error || "worker pulse could not observe the role",
|
|
101
|
+
observation.status || "blocked");
|
|
102
|
+
}
|
|
103
|
+
const agent = observation.observation ?? {};
|
|
104
|
+
const status = typeof agent.status === "string" ? agent.status : "unknown";
|
|
105
|
+
return {
|
|
106
|
+
role,
|
|
107
|
+
alive: status !== "unknown" && status !== "gone",
|
|
108
|
+
status,
|
|
109
|
+
dispatchEligible: status === "idle",
|
|
110
|
+
observed: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Queue progression observes the next dispatchable item without creating
|
|
115
|
+
// duplicate task cards. The injected task store is read-only here; marking a
|
|
116
|
+
// dispatched task done is the worker's job through its own task tools.
|
|
117
|
+
export function nextDispatchableTask(taskStore) {
|
|
118
|
+
if (!taskStore || typeof taskStore.list !== "function") {
|
|
119
|
+
throw dispatchError("task-store-invalid", "a read-only task store is required to observe the queue", "denied");
|
|
120
|
+
}
|
|
121
|
+
const tasks = taskStore.list();
|
|
122
|
+
if (!Array.isArray(tasks)) throw dispatchError("task-store-invalid", "the task store did not return a task list", "denied");
|
|
123
|
+
return tasks.find((task) => task
|
|
124
|
+
&& task.status === "pending"
|
|
125
|
+
&& !(Array.isArray(task.blockedBy) && task.blockedBy.length > 0)
|
|
126
|
+
&& !task.owner) ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Tasks already dispatched during this journey are excluded from re-selection
|
|
130
|
+
// without mutating the store: the journey keeps its own dispatched set.
|
|
131
|
+
function selectNextTask(taskStore, dispatched) {
|
|
132
|
+
for (let attempt = 0; attempt < 32; attempt += 1) {
|
|
133
|
+
const task = nextDispatchableTask(taskStore);
|
|
134
|
+
if (!task) return null;
|
|
135
|
+
if (!dispatched.has(task.id)) return task;
|
|
136
|
+
// The observed head is already dispatched on this journey; ask the store
|
|
137
|
+
// to advance by observing its next state. If the store never changes, the
|
|
138
|
+
// loop exits via the attempt bound and the journey ends as exhausted.
|
|
139
|
+
if (typeof taskStore.observeAdvance === "function") taskStore.observeAdvance(task.id);
|
|
140
|
+
else return null;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function journeyReceipt(journey) {
|
|
146
|
+
const body = [
|
|
147
|
+
"[WORKER_JOURNEY_REPORT_BEGIN]",
|
|
148
|
+
`mode: ${journey.mode}`,
|
|
149
|
+
`role: ${journey.role}`,
|
|
150
|
+
`steps: ${journey.steps.length}`,
|
|
151
|
+
`status: ${journey.status}`,
|
|
152
|
+
...(journey.handoff ? [`handoff: attempted=${journey.handoff.attempted} ok=${journey.handoff.ok ?? false} role=${journey.handoff.role ?? journey.role} reason=${journey.handoff.reason ?? "none"}`] : []),
|
|
153
|
+
...journey.steps.map((step, index) =>
|
|
154
|
+
`step ${index + 1}: task=${step.taskId ?? "none"} status=${step.status} report=${step.report ?? "(none)"}`),
|
|
155
|
+
"[WORKER_JOURNEY_REPORT_END]",
|
|
156
|
+
].join("\n");
|
|
157
|
+
if (Buffer.byteLength(body, "utf8") > MAX_REPORT_BYTES) {
|
|
158
|
+
return `${body.slice(0, MAX_REPORT_BYTES)}\n[WORKER_JOURNEY_REPORT_TRUNCATED]`;
|
|
159
|
+
}
|
|
160
|
+
return body;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// One continuous journey. Each step: pulse (liveness + eligibility), observe
|
|
164
|
+
// the next dispatchable item, one prompt exchange (no retry on any failure),
|
|
165
|
+
// read the marked report, collate. Bounded by maxSteps, never wall-clock.
|
|
166
|
+
// A worker that is not idle within an observed exchange cycle is unresponsive for
|
|
167
|
+
// dispatch purposes and ends the journey explicitly as worker-unresponsive.
|
|
168
|
+
export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
169
|
+
const mode = params.mode ?? DEFAULT_MODE;
|
|
170
|
+
const maxSteps = params.maxSteps ?? DEFAULT_JOURNEY_STEPS;
|
|
171
|
+
if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > MAX_JOURNEY_STEPS) {
|
|
172
|
+
return failure("dispatch", dispatchError("max-steps-invalid",
|
|
173
|
+
`maxSteps must be an integer between 1 and ${MAX_JOURNEY_STEPS}`, "denied"));
|
|
174
|
+
}
|
|
175
|
+
const role = params.role;
|
|
176
|
+
const stepPrompt = params.stepPrompt;
|
|
177
|
+
const taskStore = options.taskStore;
|
|
178
|
+
const spawnReplacement = typeof options.spawnReplacement === "function" ? options.spawnReplacement : null;
|
|
179
|
+
const journey = { mode, role, steps: [], status: "failed", code: null, handoff: null };
|
|
180
|
+
const dispatched = new Set();
|
|
181
|
+
const communicationOptions = options.communication ?? options;
|
|
182
|
+
|
|
183
|
+
const finish = (status) => ({
|
|
184
|
+
schema: WORKER_DISPATCH_SCHEMA,
|
|
185
|
+
ok: status === "completed" || status === "exhausted" || status === "waiting-approval",
|
|
186
|
+
action: "dispatch",
|
|
187
|
+
mode,
|
|
188
|
+
role,
|
|
189
|
+
status,
|
|
190
|
+
steps: journey.steps,
|
|
191
|
+
stepCount: journey.steps.filter((step) => step.status === "done").length,
|
|
192
|
+
code: journey.code,
|
|
193
|
+
report: journeyReceipt(journey),
|
|
194
|
+
reportMarkers: { open: "[WORKER_JOURNEY_REPORT_BEGIN]", close: "[WORKER_JOURNEY_REPORT_END]" },
|
|
195
|
+
handoff: journey.handoff,
|
|
196
|
+
nonAuthorizing: true,
|
|
197
|
+
persisted: false,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Explicit unstuck path: with native confirmation, spin up a replacement
|
|
201
|
+
// worker for the same trusted repository/role through the existing
|
|
202
|
+
// herdr-lifecycle spawn boundary (fixed argv, shell:false) and resume the
|
|
203
|
+
// pending task sequence. Reuses the same task cards; never duplicates them.
|
|
204
|
+
const handoffToReplacement = async (reason, taskId = null) => {
|
|
205
|
+
journey.status = "worker-unresponsive";
|
|
206
|
+
journey.code = "worker-unresponsive";
|
|
207
|
+
journey.steps.push({ step: journey.steps.length + 1, taskId, status: "worker-unresponsive", error: reason });
|
|
208
|
+
if (!spawnReplacement) {
|
|
209
|
+
journey.handoff = { attempted: false, reason: "replacement spawn is not available in this context" };
|
|
210
|
+
return finish("worker-unresponsive");
|
|
211
|
+
}
|
|
212
|
+
if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
|
|
213
|
+
journey.handoff = { attempted: false, reason: "native TUI confirmation unavailable for replacement spawn" };
|
|
214
|
+
return finish("worker-unresponsive");
|
|
215
|
+
}
|
|
216
|
+
let confirmed;
|
|
217
|
+
try {
|
|
218
|
+
confirmed = await context.ui.confirm("Spin up replacement worker", [
|
|
219
|
+
`Agent session for role ${role} became unresponsive (${reason}).`,
|
|
220
|
+
"Spin up one replacement worker through the guarded herdr-lifecycle spawn boundary?",
|
|
221
|
+
"The replacement resumes the same pending task sequence; existing task cards are reused, never duplicated.",
|
|
222
|
+
].join("\n"));
|
|
223
|
+
} catch (error) {
|
|
224
|
+
journey.handoff = { attempted: false, reason: `confirmation failed: ${error.message}` };
|
|
225
|
+
return finish("worker-unresponsive");
|
|
226
|
+
}
|
|
227
|
+
if (confirmed !== true) {
|
|
228
|
+
journey.handoff = { attempted: false, reason: "native confirmation was not granted for the replacement spawn" };
|
|
229
|
+
return finish("worker-unresponsive");
|
|
230
|
+
}
|
|
231
|
+
let spawned;
|
|
232
|
+
try {
|
|
233
|
+
spawned = await spawnReplacement({ role, repository: options.repository, model: options.model, context, signal });
|
|
234
|
+
} catch (error) {
|
|
235
|
+
journey.handoff = { attempted: true, ok: false, error: String(error?.message || error).slice(0, 256) };
|
|
236
|
+
return finish("worker-unresponsive");
|
|
237
|
+
}
|
|
238
|
+
journey.handoff = {
|
|
239
|
+
attempted: true,
|
|
240
|
+
ok: spawned?.ok === true,
|
|
241
|
+
role: spawned?.role ?? role,
|
|
242
|
+
repository: spawned?.repository,
|
|
243
|
+
modelArgv: spawned?.modelArgv,
|
|
244
|
+
nonAuthorizing: true,
|
|
245
|
+
};
|
|
246
|
+
return finish("worker-unresponsive");
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
if (!HERDR_COMMUNICATION_ACTIONS.includes) { /* unreachable guard */ }
|
|
250
|
+
if (mode !== "continuous" && mode !== "turn-by-turn") {
|
|
251
|
+
return failure("dispatch", dispatchError("mode-invalid", "dispatch mode must be continuous or turn-by-turn", "denied"));
|
|
252
|
+
}
|
|
253
|
+
if (!taskStore || typeof taskStore.list !== "function") {
|
|
254
|
+
return failure("dispatch", dispatchError("task-store-invalid", "a read-only task store is required", "denied"));
|
|
255
|
+
}
|
|
256
|
+
if (signal?.aborted) return finish("cancelled");
|
|
257
|
+
|
|
258
|
+
for (let stepIndex = 1; stepIndex <= maxSteps; stepIndex += 1) {
|
|
259
|
+
if (signal?.aborted) { journey.status = "cancelled"; return finish("cancelled"); }
|
|
260
|
+
|
|
261
|
+
// Pulse: liveness and dispatch eligibility, no authority.
|
|
262
|
+
let pulse;
|
|
263
|
+
try {
|
|
264
|
+
pulse = await workerPulse(role, context, communicationOptions, signal);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
journey.status = error?.code === "role_blocked" ? "role-blocked" : "failed";
|
|
267
|
+
journey.code = error?.code || "pulse-failed";
|
|
268
|
+
journey.steps.push({ step: stepIndex, taskId: null, status: journey.status, error: String(error?.message || error).slice(0, 256) });
|
|
269
|
+
return finish(journey.status);
|
|
270
|
+
}
|
|
271
|
+
if (!pulse.alive) {
|
|
272
|
+
return handoffToReplacement("worker role is not alive");
|
|
273
|
+
}
|
|
274
|
+
if (!pulse.dispatchEligible) {
|
|
275
|
+
if (pulse.status === "blocked") {
|
|
276
|
+
journey.status = "role-blocked";
|
|
277
|
+
journey.code = "role_blocked";
|
|
278
|
+
journey.steps.push({ step: stepIndex, taskId: null, status: "role-blocked", workerStatus: pulse.status });
|
|
279
|
+
return finish("role-blocked");
|
|
280
|
+
}
|
|
281
|
+
// Not idle within an observed exchange cycle: unresponsive for dispatch.
|
|
282
|
+
return handoffToReplacement(`worker not idle in the observed exchange cycle (status: ${pulse.status})`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Observe the next dispatchable item; never create or mutate cards.
|
|
286
|
+
let task;
|
|
287
|
+
try {
|
|
288
|
+
task = selectNextTask(taskStore, dispatched);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
journey.status = "failed";
|
|
291
|
+
journey.steps.push({ step: stepIndex, taskId: null, status: "failed", error: String(error?.message || error).slice(0, 256) });
|
|
292
|
+
return finish("failed");
|
|
293
|
+
}
|
|
294
|
+
if (!task) {
|
|
295
|
+
journey.status = "exhausted";
|
|
296
|
+
journey.steps.push({ step: stepIndex, taskId: null, status: "exhausted" });
|
|
297
|
+
return finish("exhausted");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Interactive opt-in: stop after each step for explicit approval.
|
|
301
|
+
if (mode === "turn-by-turn" && stepIndex > 1) {
|
|
302
|
+
journey.status = "waiting-approval";
|
|
303
|
+
journey.steps.push({ step: stepIndex, taskId: task.id, status: "waiting-approval" });
|
|
304
|
+
return finish("waiting-approval");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Consequential dispatch requires native confirmation, once per step.
|
|
308
|
+
if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
|
|
309
|
+
journey.status = "failed";
|
|
310
|
+
journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: "native TUI confirmation unavailable" });
|
|
311
|
+
return finish("failed");
|
|
312
|
+
}
|
|
313
|
+
let confirmed;
|
|
314
|
+
try {
|
|
315
|
+
confirmed = await context.ui.confirm("Dispatch task to worker", [
|
|
316
|
+
`Dispatch one bounded step to role ${role}?`,
|
|
317
|
+
`Task: ${task.id}${task.subject ? ` — ${task.subject}` : ""}`,
|
|
318
|
+
`Mode: ${mode} (step ${stepIndex} of at most ${maxSteps})`,
|
|
319
|
+
"One prompt exchange, no retries; the worker returns one marked report.",
|
|
320
|
+
].join("\n"));
|
|
321
|
+
} catch (error) {
|
|
322
|
+
journey.status = "failed";
|
|
323
|
+
journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: `confirmation failed: ${error.message}` });
|
|
324
|
+
return finish("failed");
|
|
325
|
+
}
|
|
326
|
+
if (confirmed !== true) {
|
|
327
|
+
journey.status = "cancelled";
|
|
328
|
+
journey.steps.push({ step: stepIndex, taskId: task.id, status: "cancelled", error: "native confirmation was not granted" });
|
|
329
|
+
return finish("cancelled");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// One prompt exchange. Any failure is terminal for the journey; there is
|
|
333
|
+
// no invisible retry or resend.
|
|
334
|
+
const exchange = await executeHerdrCommunication(
|
|
335
|
+
{ action: "prompt", role, prompt: `${stepPrompt}\nTask: ${task.id}${task.subject ? ` — ${task.subject}` : ""}`, timeoutMs: 120000 },
|
|
336
|
+
context,
|
|
337
|
+
communicationOptions,
|
|
338
|
+
signal,
|
|
339
|
+
);
|
|
340
|
+
if (exchange.ok !== true) {
|
|
341
|
+
const unresponsive = exchange.code === "prompt_stalled" || exchange.code === "process_timeout";
|
|
342
|
+
if (unresponsive) {
|
|
343
|
+
return handoffToReplacement(`exchange ended with ${exchange.code}`, task.id);
|
|
344
|
+
}
|
|
345
|
+
journey.status = exchange.code === "role_blocked" ? "role-blocked" : "failed";
|
|
346
|
+
journey.code = exchange.code || "exchange-failed";
|
|
347
|
+
journey.steps.push({ step: stepIndex, taskId: task.id, status: journey.status, error: exchange.reason || exchange.error || exchange.code });
|
|
348
|
+
return finish(journey.status);
|
|
349
|
+
}
|
|
350
|
+
journey.steps.push({
|
|
351
|
+
step: stepIndex,
|
|
352
|
+
taskId: task.id,
|
|
353
|
+
status: "done",
|
|
354
|
+
workerStatus: exchange.agentStatus,
|
|
355
|
+
report: exchange.report,
|
|
356
|
+
});
|
|
357
|
+
dispatched.add(task.id);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
journey.status = "completed";
|
|
361
|
+
return finish("completed");
|
|
362
|
+
// Unreachable in correct use: every step either dispatches one pending
|
|
363
|
+
// task, or the queue observation returns null and the journey ends with
|
|
364
|
+
// "exhausted". A task store that never drains hits maxSteps and lands here;
|
|
365
|
+
// "completed" then reflects the step bound, and callers inspect stepCount.
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function registerWorkerDispatchInterface(pi, options = {}) {
|
|
369
|
+
if (typeof pi?.registerTool !== "function" || REGISTRATIONS.has(pi)) return;
|
|
370
|
+
REGISTRATIONS.add(pi);
|
|
371
|
+
pi.registerTool({
|
|
372
|
+
name: WORKER_DISPATCH_TOOL,
|
|
373
|
+
label: "Worker Dispatch And Pulse",
|
|
374
|
+
description: "Observe worker liveness (pulse) or run one bounded continuous worker journey over the existing task sequence. Continuous mode is the default; turn-by-turn is explicit opt-in. Journeys emit one collated marked report, never create task cards, never retry, and are bounded by step count, not wall-clock.",
|
|
375
|
+
promptSnippet: "Use agentic_worker_dispatch to pulse a worker or run one bounded continuous journey over the existing task sequence; it observes dispatchable tasks without creating cards and grants no authority.",
|
|
376
|
+
promptGuidelines: [
|
|
377
|
+
"agentic_worker_dispatch pulse observes liveness, state, and dispatch eligibility without granting authority.",
|
|
378
|
+
"agentic_worker_dispatch dispatch runs at most maxSteps single-exchange steps; any exchange failure ends the journey explicitly with no retry or resend.",
|
|
379
|
+
],
|
|
380
|
+
parameters: WORKER_DISPATCH_PARAMETERS,
|
|
381
|
+
async execute(_id, params, signal, _update, context) {
|
|
382
|
+
let value;
|
|
383
|
+
if (params?.action === "pulse") {
|
|
384
|
+
try {
|
|
385
|
+
const pulse = await workerPulse(params.role, context, options, signal);
|
|
386
|
+
value = { schema: WORKER_DISPATCH_SCHEMA, ok: true, action: "pulse", ...pulse, nonAuthorizing: true, persisted: false };
|
|
387
|
+
} catch (error) {
|
|
388
|
+
value = failure("pulse", error);
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
value = await runWorkerJourney(params ?? {}, context, options, signal);
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
395
|
+
details: value,
|
|
396
|
+
};
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export { HERDR_REPORT_MARKERS, HERDR_COMMUNICATION_SCHEMA };
|
|
402
|
+
export default registerWorkerDispatchInterface;
|