@evoclock/pi-agentic-driver 0.9.0 → 0.9.1
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/README.md +6 -1
- package/extensions/herdr-communication.ts +8 -1
- package/extensions/herdr-lifecycle.ts +4 -3
- package/extensions/pulse.ts +74 -0
- package/package.json +6 -2
- package/scripts/enforcement/herdr_async_dispatch_pi.js +65 -3
- package/scripts/enforcement/herdr_communication_pi.js +42 -11
- package/scripts/enforcement/pulse_core_pi.js +268 -0
- package/scripts/enforcement/pulse_scheduler_pi.js +920 -0
- package/scripts/enforcement/task_board_core_pi.js +348 -10
- package/skills/pulse/SKILL.md +39 -0
|
@@ -1389,6 +1389,7 @@ const CONSUMED_REASONS = Object.freeze([
|
|
|
1389
1389
|
"waiting-approval", "role-blocked", "worker-unresponsive",
|
|
1390
1390
|
]);
|
|
1391
1391
|
const TRANSACTION_FIELDS = Object.freeze(["op", "cardId", "envelopeId", "at", "phase"]);
|
|
1392
|
+
const REPLACE_TRANSACTION_FIELDS = Object.freeze(["op", "cardId", "envelopeId", "at", "phase", "oldEnvelopeId", "oldReason"]);
|
|
1392
1393
|
const CLAIMS_STATE_FIELDS = Object.freeze(["schema", "generation", "transaction", "claims", "consumedClaims", "hmac"]);
|
|
1393
1394
|
|
|
1394
1395
|
function wellFormedClaim(claim) {
|
|
@@ -1411,11 +1412,25 @@ function wellFormedConsumedClaim(claim) {
|
|
|
1411
1412
|
}
|
|
1412
1413
|
|
|
1413
1414
|
function wellFormedTransaction(tx) {
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1415
|
+
if (tx === null) return true;
|
|
1416
|
+
if (typeof tx !== "object" || Array.isArray(tx)) return false;
|
|
1417
|
+
if (tx.op === "claim") {
|
|
1418
|
+
return exactKeys(tx, TRANSACTION_FIELDS)
|
|
1419
|
+
&& CARD_ID_RE.test(tx.cardId)
|
|
1417
1420
|
&& /^[0-9a-f]{32}$/.test(tx.envelopeId) && ISO_TS_RE.test(tx.at)
|
|
1418
|
-
&& tx.phase === "claims-written"
|
|
1421
|
+
&& tx.phase === "claims-written";
|
|
1422
|
+
}
|
|
1423
|
+
// §10.4 replace-attempt transaction: the pre-recorded replacement identity
|
|
1424
|
+
// plus the old envelope ID and terminal reason it replaces.
|
|
1425
|
+
if (tx.op === "replace-attempt") {
|
|
1426
|
+
return exactKeys(tx, REPLACE_TRANSACTION_FIELDS)
|
|
1427
|
+
&& CARD_ID_RE.test(tx.cardId)
|
|
1428
|
+
&& /^[0-9a-f]{32}$/.test(tx.envelopeId) && /^[0-9a-f]{32}$/.test(tx.oldEnvelopeId)
|
|
1429
|
+
&& ISO_TS_RE.test(tx.at)
|
|
1430
|
+
&& tx.phase === "claims-written"
|
|
1431
|
+
&& CONSUMED_REASONS.includes(tx.oldReason);
|
|
1432
|
+
}
|
|
1433
|
+
return false;
|
|
1419
1434
|
}
|
|
1420
1435
|
|
|
1421
1436
|
// Read and VERIFY the claims file. Missing file → fresh ONLY while the
|
|
@@ -1567,7 +1582,103 @@ export function readAutomationPolicy(boardPath, { configPath = null } = {}) {
|
|
|
1567
1582
|
const POLICY_FIELDS = Object.freeze([
|
|
1568
1583
|
"roles", "placement", "maxConcurrent", "expiry", "envelopeExpiryHours",
|
|
1569
1584
|
"board", "riskCeiling", "allowPerCardRiskOverride", "acceptedRepositories",
|
|
1585
|
+
"pulse",
|
|
1586
|
+
]);
|
|
1587
|
+
|
|
1588
|
+
// Pulse policy v1 (PULSE_DESIGN_v3 §3): one optional closed `pulse` object
|
|
1589
|
+
// inside the existing board-bound automation policy. No second policy store:
|
|
1590
|
+
// the pulse record is validated here, beside the shipped policy fields, and
|
|
1591
|
+
// a policy without `pulse` behaves exactly as before (Pulse disabled).
|
|
1592
|
+
const PULSE_FIELDS = Object.freeze([
|
|
1593
|
+
"enabled", "mode", "intervalSeconds", "fillOnStart", "routing",
|
|
1594
|
+
"stallTimeoutSeconds", "unattendedHostRiskAccepted",
|
|
1570
1595
|
]);
|
|
1596
|
+
const PULSE_ROUTE_FIELDS = Object.freeze(["model", "maxConcurrent"]);
|
|
1597
|
+
const PULSE_MODES = Object.freeze(["interactive", "automated"]);
|
|
1598
|
+
const PULSE_MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
1599
|
+
|
|
1600
|
+
function pulseCapacityOk(value) {
|
|
1601
|
+
return Number.isInteger(value) && value >= 1 && value <= 32;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
function checkPulseRouteEntry(entry, { seen }) {
|
|
1605
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)
|
|
1606
|
+
|| !exactKeys(entry, PULSE_ROUTE_FIELDS)) {
|
|
1607
|
+
return "each routing entry must have exactly {model, maxConcurrent}";
|
|
1608
|
+
}
|
|
1609
|
+
if (typeof entry.model !== "string" || !PULSE_MODEL_RE.test(entry.model)) {
|
|
1610
|
+
return `route model "${entry.model}" is not an exact provider/model identifier`;
|
|
1611
|
+
}
|
|
1612
|
+
if (seen.has(entry.model)) {
|
|
1613
|
+
return `model "${entry.model}" appears more than once for this role`;
|
|
1614
|
+
}
|
|
1615
|
+
seen.add(entry.model);
|
|
1616
|
+
if (!pulseCapacityOk(entry.maxConcurrent)) {
|
|
1617
|
+
return `route maxConcurrent for "${entry.model}" must be an integer from 1 through 32`;
|
|
1618
|
+
}
|
|
1619
|
+
return null;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// Validate the optional `pulse` object. Returns {ok, pulse, reason}. Fails
|
|
1623
|
+
// closed on unknown fields, duplicate models, routing roles absent from the
|
|
1624
|
+
// policy's declared roles, and invalid capacities.
|
|
1625
|
+
export function checkPulsePolicy(pulse, { roles = null } = {}) {
|
|
1626
|
+
if (pulse === null || pulse === undefined) return { ok: true, pulse: null, reason: null };
|
|
1627
|
+
if (typeof pulse !== "object" || Array.isArray(pulse) || !exactKeys(pulse, PULSE_FIELDS)) {
|
|
1628
|
+
return { ok: false, pulse: null, reason: "the automation policy pulse object has unknown or missing fields — the pulse shape is closed (fails closed)" };
|
|
1629
|
+
}
|
|
1630
|
+
if (typeof pulse.enabled !== "boolean") {
|
|
1631
|
+
return { ok: false, pulse: null, reason: "pulse.enabled is required and must be a boolean (fails closed)" };
|
|
1632
|
+
}
|
|
1633
|
+
if (!PULSE_MODES.includes(pulse.mode)) {
|
|
1634
|
+
return { ok: false, pulse: null, reason: `pulse.mode must be one of ${PULSE_MODES.join(", ")} (fails closed)` };
|
|
1635
|
+
}
|
|
1636
|
+
if (!Number.isInteger(pulse.intervalSeconds) || pulse.intervalSeconds < 10 || pulse.intervalSeconds > 86400) {
|
|
1637
|
+
return { ok: false, pulse: null, reason: "pulse.intervalSeconds must be an integer from 10 through 86400 (fails closed)" };
|
|
1638
|
+
}
|
|
1639
|
+
if (typeof pulse.fillOnStart !== "boolean") {
|
|
1640
|
+
return { ok: false, pulse: null, reason: "pulse.fillOnStart must be a boolean (fails closed)" };
|
|
1641
|
+
}
|
|
1642
|
+
if (pulse.routing === null || typeof pulse.routing !== "object" || Array.isArray(pulse.routing)) {
|
|
1643
|
+
return { ok: false, pulse: null, reason: "pulse.routing must be an object keyed by declared policy role (fails closed)" };
|
|
1644
|
+
}
|
|
1645
|
+
const declared = Array.isArray(roles) ? roles : [];
|
|
1646
|
+
for (const role of Object.keys(pulse.routing)) {
|
|
1647
|
+
if (!declared.includes(role)) {
|
|
1648
|
+
return { ok: false, pulse: null, reason: `pulse.routing declares role "${role}", which is absent from the policy roles (fails closed)` };
|
|
1649
|
+
}
|
|
1650
|
+
const entry = pulse.routing[role];
|
|
1651
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)
|
|
1652
|
+
|| !exactKeys(entry, ["preferred", "fallback", "maxConcurrent"])) {
|
|
1653
|
+
return { ok: false, pulse: null, reason: `pulse.routing["${role}"] must have exactly {preferred, fallback, maxConcurrent} (fails closed)` };
|
|
1654
|
+
}
|
|
1655
|
+
if (!Array.isArray(entry.preferred) || entry.preferred.length === 0) {
|
|
1656
|
+
return { ok: false, pulse: null, reason: `pulse.routing["${role}"].preferred must list at least one route (fails closed)` };
|
|
1657
|
+
}
|
|
1658
|
+
if (!Array.isArray(entry.fallback)) {
|
|
1659
|
+
return { ok: false, pulse: null, reason: `pulse.routing["${role}"].fallback must be a list (fails closed)` };
|
|
1660
|
+
}
|
|
1661
|
+
const seen = new Set();
|
|
1662
|
+
for (const route of entry.preferred) {
|
|
1663
|
+
const reason = checkPulseRouteEntry(route, { seen });
|
|
1664
|
+
if (reason) return { ok: false, pulse: null, reason: `pulse.routing["${role}"].preferred: ${reason} (fails closed)` };
|
|
1665
|
+
}
|
|
1666
|
+
for (const route of entry.fallback) {
|
|
1667
|
+
const reason = checkPulseRouteEntry(route, { seen });
|
|
1668
|
+
if (reason) return { ok: false, pulse: null, reason: `pulse.routing["${role}"].fallback: ${reason} (fails closed)` };
|
|
1669
|
+
}
|
|
1670
|
+
if (!pulseCapacityOk(entry.maxConcurrent)) {
|
|
1671
|
+
return { ok: false, pulse: null, reason: `pulse.routing["${role}"].maxConcurrent must be an integer from 1 through 32 (fails closed)` };
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
if (!Number.isInteger(pulse.stallTimeoutSeconds) || pulse.stallTimeoutSeconds < 30 || pulse.stallTimeoutSeconds > 86400) {
|
|
1675
|
+
return { ok: false, pulse: null, reason: "pulse.stallTimeoutSeconds must be an integer from 30 through 86400 (fails closed)" };
|
|
1676
|
+
}
|
|
1677
|
+
if (typeof pulse.unattendedHostRiskAccepted !== "boolean") {
|
|
1678
|
+
return { ok: false, pulse: null, reason: "pulse.unattendedHostRiskAccepted must be a boolean (fails closed)" };
|
|
1679
|
+
}
|
|
1680
|
+
return { ok: true, pulse, reason: null };
|
|
1681
|
+
}
|
|
1571
1682
|
|
|
1572
1683
|
export function checkAutomationPolicy(policy, { now = null, boardPath = null } = {}) {
|
|
1573
1684
|
const at = now ?? new Date().toISOString();
|
|
@@ -1623,6 +1734,14 @@ export function checkAutomationPolicy(policy, { now = null, boardPath = null } =
|
|
|
1623
1734
|
|| !policy.acceptedRepositories.every((repo) => typeof repo === "string" && repo !== "" && !repo.includes(".."))) {
|
|
1624
1735
|
return { ok: false, reason: "the automation policy acceptedRepositories must be a non-empty closed list of repository paths (fails closed)" };
|
|
1625
1736
|
}
|
|
1737
|
+
|
|
1738
|
+
// Pulse (§3): the optional closed pulse object is validated in place. A
|
|
1739
|
+
// policy without `pulse` is unchanged and means Pulse is disabled.
|
|
1740
|
+
if (policy.pulse !== undefined) {
|
|
1741
|
+
const pulse = checkPulsePolicy(policy.pulse, { roles: policy.roles });
|
|
1742
|
+
if (!pulse.ok) return { ok: false, reason: pulse.reason };
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1626
1745
|
return { ok: true, policy, reason: null };
|
|
1627
1746
|
}
|
|
1628
1747
|
|
|
@@ -1746,7 +1865,62 @@ export function selectDispatchableCard({ cards, boardPath, activeClaims, cardId
|
|
|
1746
1865
|
// the new complete state (both writes are atomic renames); two concurrent
|
|
1747
1866
|
// claims can never both win because the entire read-decide-write sequence
|
|
1748
1867
|
// holds the lock. Expired claims are released first, inside the same lock.
|
|
1749
|
-
export function claimCard({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision
|
|
1868
|
+
export function claimCard({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision }) {
|
|
1869
|
+
return claimCardInternal({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch: null });
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// ---------------------------------------------------------------------------
|
|
1873
|
+
// Trusted Pulse-internal confirmed-batch claim path. Host placement is
|
|
1874
|
+
// claimable ONLY here, and only for a batch context object this module has
|
|
1875
|
+
// registered after one native confirmation (registerConfirmedBatchForClaims),
|
|
1876
|
+
// matched by object identity — never by batchId string, user/model field, or
|
|
1877
|
+
// durable state. Each confirmed entry is single-use: one claim consumes it,
|
|
1878
|
+
// and a claim for a card outside the confirmed set is refused.
|
|
1879
|
+
// ---------------------------------------------------------------------------
|
|
1880
|
+
|
|
1881
|
+
// batch context object (WeakMap key) -> Set of cardIds still claimable from it.
|
|
1882
|
+
const confirmedBatchClaims = new WeakMap();
|
|
1883
|
+
|
|
1884
|
+
export function registerConfirmedBatchForClaims(batch) {
|
|
1885
|
+
if (batch?.schema !== "agentic-driver.pulse-batch.v1" || typeof batch.batchId !== "string" || !Array.isArray(batch.entries)) {
|
|
1886
|
+
throw new Error("registerConfirmedBatchForClaims requires a minted pulse batch context");
|
|
1887
|
+
}
|
|
1888
|
+
confirmedBatchClaims.set(batch, new Set(batch.entries.map((entry) => entry.cardId)));
|
|
1889
|
+
return batch.batchId;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
export function claimCardForConfirmedBatch({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision = null, confirmedBatch = null }) {
|
|
1893
|
+
if (confirmedBatch === null || !confirmedBatchClaims.has(confirmedBatch)) {
|
|
1894
|
+
return { ok: false, code: "confirmed-batch-required", reason: "host placement is claimable only through the Pulse confirmed-batch path with a batch registered after native confirmation (fails closed)" };
|
|
1895
|
+
}
|
|
1896
|
+
if (typeof cardId !== "string" || cardId === "") {
|
|
1897
|
+
return { ok: false, code: "confirmed-batch-card-required", reason: "a confirmed-batch claim requires an explicit cardId from the confirmed batch (fails closed)" };
|
|
1898
|
+
}
|
|
1899
|
+
const expiresMs = Date.parse(confirmedBatch.expiresAt);
|
|
1900
|
+
const nowMs = Date.parse(now ?? new Date().toISOString());
|
|
1901
|
+
if (!Number.isFinite(expiresMs) || !Number.isFinite(nowMs)) {
|
|
1902
|
+
return { ok: false, code: "confirmed-batch-time-invalid", reason: "the confirmed batch expiry or current time is invalid (fails closed)" };
|
|
1903
|
+
}
|
|
1904
|
+
if (nowMs >= expiresMs) {
|
|
1905
|
+
return { ok: false, code: "confirmed-batch-expired", reason: "the confirmed batch context has expired; a fresh preview and confirmation is required" };
|
|
1906
|
+
}
|
|
1907
|
+
const entry = confirmedBatch.entries.find((e) => e.cardId === cardId);
|
|
1908
|
+
if (!entry || entry.placement !== "host") {
|
|
1909
|
+
return { ok: false, code: "confirmed-batch-card-mismatch", reason: "the requested card is not a host entry in the confirmed batch; confirmations cannot be crossed between batch entries" };
|
|
1910
|
+
}
|
|
1911
|
+
if (entry.state !== "reserved") {
|
|
1912
|
+
return { ok: false, code: "confirmed-batch-entry-not-reserved", reason: `confirmed batch entry is ${entry.state}, not reserved; it cannot be claimed again (single-use)` };
|
|
1913
|
+
}
|
|
1914
|
+
const claimable = confirmedBatchClaims.get(confirmedBatch);
|
|
1915
|
+
if (!claimable.has(cardId)) {
|
|
1916
|
+
return { ok: false, code: "confirmed-batch-entry-consumed", reason: "the confirmed batch entry was already claimed; confirmation is single-use" };
|
|
1917
|
+
}
|
|
1918
|
+
const result = claimCardInternal({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch });
|
|
1919
|
+
if (result.ok) claimable.delete(cardId); // consume the single-use confirmation
|
|
1920
|
+
return result;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
function claimCardInternal({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision = null, confirmedBatch = null }) {
|
|
1750
1924
|
if (typeof boardPath !== "string" || boardPath === "") {
|
|
1751
1925
|
throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
|
|
1752
1926
|
}
|
|
@@ -1760,7 +1934,7 @@ export function claimCard({ boardPath, cardId = null, role, policy = null, confi
|
|
|
1760
1934
|
for (let attempt = 0; ; attempt += 1) {
|
|
1761
1935
|
try {
|
|
1762
1936
|
return withWriterLock(boardPath, () =>
|
|
1763
|
-
claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision }));
|
|
1937
|
+
claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch }));
|
|
1764
1938
|
} catch (error) {
|
|
1765
1939
|
if (error?.code === "writer-lock-held" && attempt < 20) {
|
|
1766
1940
|
sleepSync(25);
|
|
@@ -1774,7 +1948,7 @@ export function claimCard({ boardPath, cardId = null, role, policy = null, confi
|
|
|
1774
1948
|
}
|
|
1775
1949
|
}
|
|
1776
1950
|
|
|
1777
|
-
function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision }) {
|
|
1951
|
+
function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch = null }) {
|
|
1778
1952
|
const at = now ?? new Date().toISOString();
|
|
1779
1953
|
if (!existsSync(boardPath)) {
|
|
1780
1954
|
return { ok: false, code: "board-unavailable", reason: "board file is no longer present (board-unavailable)" };
|
|
@@ -1813,9 +1987,17 @@ function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, rep
|
|
|
1813
1987
|
if (!policyCheck.policy.roles.includes(role)) {
|
|
1814
1988
|
return { ok: false, code: "policy-role-refused", reason: `role "${role}" is not declared in the automation policy (fails closed)` };
|
|
1815
1989
|
}
|
|
1816
|
-
// §3.4 mode table:
|
|
1817
|
-
|
|
1818
|
-
|
|
1990
|
+
// §3.4 mode table: ordinary claims are contained-only (container/microVM),
|
|
1991
|
+
// always. Host placement is claimable ONLY through the trusted Pulse-internal
|
|
1992
|
+
// confirmed-batch path (claimCardForConfirmedBatch), which requires a batch
|
|
1993
|
+
// context registered after one native confirmation. Static pulse.mode=
|
|
1994
|
+
// "interactive" plus host placement is never enough on its own; automated
|
|
1995
|
+
// dispatch may never use host placement.
|
|
1996
|
+
if (confirmedBatch === null && policyCheck.policy.placement !== "container") {
|
|
1997
|
+
return { ok: false, code: "policy-placement-refused", reason: "ordinary board dispatch requires container placement per the automation policy (§3.4); host placement is claimable only through the Pulse confirmed-batch path" };
|
|
1998
|
+
}
|
|
1999
|
+
if (confirmedBatch !== null && policyCheck.policy.placement !== "host") {
|
|
2000
|
+
return { ok: false, code: "policy-placement-refused", reason: "the Pulse confirmed-batch claim path requires host placement (fails closed)" };
|
|
1819
2001
|
}
|
|
1820
2002
|
// F4: the starting revision comes from the card's repository — the
|
|
1821
2003
|
// workspace the board lives in, not process.cwd(). A caller-supplied
|
|
@@ -2186,6 +2368,162 @@ export function reclaimClaim({ boardPath, cardId = null, envelopeId = null } = {
|
|
|
2186
2368
|
});
|
|
2187
2369
|
}
|
|
2188
2370
|
|
|
2371
|
+
// §10.4 dispatcher-owned recoverable replaceAttempt: one transition under the
|
|
2372
|
+
// writer lock that replaces a failed/unresponsive attempt with a new attempt
|
|
2373
|
+
// (new envelope, new claim) for the same card. Trusted input: the expected
|
|
2374
|
+
// active card ID, old envelope ID, terminal reason, and replacement route
|
|
2375
|
+
// (role/repository/starting revision). Not a public model action.
|
|
2376
|
+
//
|
|
2377
|
+
// Transaction shape: a prepared transaction is durably staged (HMAC-covered
|
|
2378
|
+
// via the claims file's own authentication) containing the old terminal
|
|
2379
|
+
// evidence and the pre-recorded new attempt identity. Only then is the old
|
|
2380
|
+
// envelope consumed and the new claim published; the transaction record
|
|
2381
|
+
// clears once the projection is republished. Recovery is idempotent and runs
|
|
2382
|
+
// first: if only the old consumption landed, the replacement rolls forward
|
|
2383
|
+
// only while all bound identities still match; otherwise it returns
|
|
2384
|
+
// REVIEW_REQUIRED. Recovery never creates another envelope.
|
|
2385
|
+
const REPLACE_TRANSACTION_OPS = Object.freeze(["replace-attempt"]);
|
|
2386
|
+
const REPLACE_TRANSACTION_PHASES = Object.freeze(["prepared", "committed"]);
|
|
2387
|
+
|
|
2388
|
+
export function replaceAttempt({ boardPath, cardId, envelopeId, reason, replacement = {}, now = null } = {}) {
|
|
2389
|
+
if (typeof boardPath !== "string" || boardPath === "") {
|
|
2390
|
+
throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
|
|
2391
|
+
}
|
|
2392
|
+
if (typeof cardId !== "string" || !CARD_ID_RE.test(cardId)) {
|
|
2393
|
+
throw Object.assign(new Error("cardId must be a well-formed card ID"), { code: "invalid-input" });
|
|
2394
|
+
}
|
|
2395
|
+
if (typeof envelopeId !== "string" || !/^[0-9a-f]{32}$/.test(envelopeId)) {
|
|
2396
|
+
throw Object.assign(new Error("envelopeId must be a 32-hex envelope ID"), { code: "invalid-input" });
|
|
2397
|
+
}
|
|
2398
|
+
if (!CONSUMED_REASONS.includes(reason)) {
|
|
2399
|
+
throw Object.assign(new Error(`reason must be one of ${CONSUMED_REASONS.join(", ")}`), { code: "invalid-input" });
|
|
2400
|
+
}
|
|
2401
|
+
const at = now ?? new Date().toISOString();
|
|
2402
|
+
return withWriterLock(boardPath, () => replaceAttemptLocked({ boardPath, cardId, envelopeId, reason, replacement, at }));
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
function replaceAttemptLocked({ boardPath, cardId, envelopeId, reason, replacement, at }) {
|
|
2406
|
+
// Recovery first (idempotent): roll any interrupted prior transaction
|
|
2407
|
+
// forward before observing state, never creating another envelope.
|
|
2408
|
+
const anchorRecovery = recoverClaimsAnchorLocked(boardPath);
|
|
2409
|
+
if (!anchorRecovery.ok) {
|
|
2410
|
+
return { ok: false, code: "claims-anchor-recovery-failed", recoverable: true, reason: anchorRecovery.reason };
|
|
2411
|
+
}
|
|
2412
|
+
const read = readClaimsState(boardPath);
|
|
2413
|
+
if (!read.ok) return { ok: false, code: "claims-corrupt", reason: read.reason };
|
|
2414
|
+
const reconciliation = reconcileTransactionLocked({ boardPath, state: read.state });
|
|
2415
|
+
if (reconciliation?.failed) {
|
|
2416
|
+
return { ok: false, code: "recovery-failed", recoverable: true, reason: reconciliation.reason };
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
// The old attempt must be the exact active authenticated claim for this
|
|
2420
|
+
// card. Drift, reuse, or a consumed old envelope fails closed.
|
|
2421
|
+
const oldClaim = read.state.claims.find((claim) => claim.envelopeId === envelopeId);
|
|
2422
|
+
if (!oldClaim) {
|
|
2423
|
+
return { ok: false, code: "envelope-not-active",
|
|
2424
|
+
reason: read.state.consumedClaims.some((entry) => entry.envelopeId === envelopeId)
|
|
2425
|
+
? "the old envelope attempt was already consumed — a replacement requires a fresh dispatch (single-attempt lifecycle)"
|
|
2426
|
+
: `no active claim for envelope ${envelopeId} (fails closed)` };
|
|
2427
|
+
}
|
|
2428
|
+
if (oldClaim.cardId !== cardId) {
|
|
2429
|
+
return { ok: false, code: "card-drift", reason: `envelope ${envelopeId} is bound to card ${oldClaim.cardId}, not ${cardId} (fails closed)` };
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// Re-evaluate current observations under the lock: card identity/hash,
|
|
2433
|
+
// policy validity, and capacity for the replacement route.
|
|
2434
|
+
if (!existsSync(boardPath)) {
|
|
2435
|
+
return { ok: false, code: "board-unavailable", reason: "board file is no longer present (board-unavailable)" };
|
|
2436
|
+
}
|
|
2437
|
+
const validatedBoard = validateBoard(readFileSync(boardPath, "utf8"), {});
|
|
2438
|
+
if (!validatedBoard.ok) return { ok: false, code: "board-invalid", errors: validatedBoard.errors };
|
|
2439
|
+
const card = validatedBoard.cards.find((entry) => entry.cardId === cardId);
|
|
2440
|
+
if (!card) return { ok: false, code: "card-not-found", reason: `card ${cardId} no longer exists on the board (drift — REVIEW_REQUIRED)` };
|
|
2441
|
+
const currentHash = card.hash ?? computeCardHash(card);
|
|
2442
|
+
if (currentHash !== oldClaim.envelope.cardHash) {
|
|
2443
|
+
return { ok: false, code: "card-hash-drift", reason: "the card hash drifted from the old envelope binding — semantic edits require human review (REVIEW_REQUIRED)" };
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
const resolvedPolicy = readAutomationPolicy(boardPath);
|
|
2447
|
+
const policyCheck = checkAutomationPolicy(resolvedPolicy, { now: at, boardPath });
|
|
2448
|
+
if (!policyCheck.ok) {
|
|
2449
|
+
return { ok: false, code: "policy-refused", reason: policyCheck.reason };
|
|
2450
|
+
}
|
|
2451
|
+
const replacementRole = typeof replacement.role === "string" && replacement.role !== "" ? replacement.role : oldClaim.role;
|
|
2452
|
+
if (!policyCheck.policy.roles.includes(replacementRole)) {
|
|
2453
|
+
return { ok: false, code: "policy-role-refused", reason: `replacement role "${replacementRole}" is not declared in the automation policy (fails closed)` };
|
|
2454
|
+
}
|
|
2455
|
+
if (policyCheck.policy.placement !== "container") {
|
|
2456
|
+
return { ok: false, code: "policy-placement-refused", reason: "automated board dispatch requires container placement per the automation policy (§3.4)" };
|
|
2457
|
+
}
|
|
2458
|
+
const replacementRepository = typeof replacement.repository === "string" && replacement.repository !== ""
|
|
2459
|
+
? replacement.repository
|
|
2460
|
+
: dirname(boardPath);
|
|
2461
|
+
if (!repositoryAccepted(policyCheck.policy, replacementRepository)) {
|
|
2462
|
+
return { ok: false, code: "policy-repository-refused", reason: `repository "${replacementRepository}" is not on the automation policy's acceptedRepositories list (fails closed)` };
|
|
2463
|
+
}
|
|
2464
|
+
const activeClaims = releaseExpiredClaimsLocked({ boardPath, at, state: read.state });
|
|
2465
|
+
const concurrency = Number(policyCheck.policy.maxConcurrent);
|
|
2466
|
+
// Capacity: the old claim is being replaced, so it frees exactly one slot.
|
|
2467
|
+
if (activeClaims.filter((claim) => claim.envelopeId !== envelopeId).length >= concurrency) {
|
|
2468
|
+
return { ok: false, code: "policy-concurrency-refused", reason: `the automation policy allows at most ${concurrency} concurrent claim(s); no free slot for the replacement` };
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// Pre-record the replacement identity, then durably stage the prepared
|
|
2472
|
+
// transaction BEFORE consuming the old envelope. The staged claims state
|
|
2473
|
+
// carries the complete signed next value, so either crash window rolls
|
|
2474
|
+
// forward deterministically (the same prepare→claims→commit scheme as
|
|
2475
|
+
// claimCard, extended with the replace op and phase).
|
|
2476
|
+
const newEnvelope = createEnvelope({ card, policy: policyCheck.policy, now: at, repository: replacementRepository, startingRevision: replacement.startingRevision ?? undefined });
|
|
2477
|
+
const newClaim = {
|
|
2478
|
+
cardId,
|
|
2479
|
+
claimedAt: at,
|
|
2480
|
+
role: replacementRole,
|
|
2481
|
+
envelopeId: newEnvelope.envelopeId,
|
|
2482
|
+
envelope: newEnvelope,
|
|
2483
|
+
};
|
|
2484
|
+
const keptClaims = activeClaims.filter((claim) => claim.envelopeId !== envelopeId);
|
|
2485
|
+
const nextClaims = [...keptClaims, newClaim];
|
|
2486
|
+
const consumedClaims = [...read.state.consumedClaims, {
|
|
2487
|
+
cardId, envelopeId, consumedAt: at, reason,
|
|
2488
|
+
}];
|
|
2489
|
+
const transaction = {
|
|
2490
|
+
op: "replace-attempt",
|
|
2491
|
+
cardId,
|
|
2492
|
+
envelopeId: newEnvelope.envelopeId,
|
|
2493
|
+
at,
|
|
2494
|
+
phase: "claims-written",
|
|
2495
|
+
oldEnvelopeId: envelopeId,
|
|
2496
|
+
oldReason: reason,
|
|
2497
|
+
};
|
|
2498
|
+
writeClaims(boardPath, nextClaims, { consumedClaims, transaction });
|
|
2499
|
+
const projection = writeProjection(boardPath, validatedBoard.cards, nextClaims);
|
|
2500
|
+
if (!projection.written) {
|
|
2501
|
+
return {
|
|
2502
|
+
ok: false,
|
|
2503
|
+
replaced: true,
|
|
2504
|
+
code: "replace-recoverable",
|
|
2505
|
+
recoverable: true,
|
|
2506
|
+
reason: `projection publication failed: ${projection.error ?? "unknown"} (replacement committed; transaction retained for roll-forward)`,
|
|
2507
|
+
cardId,
|
|
2508
|
+
oldEnvelopeId: envelopeId,
|
|
2509
|
+
claim: newClaim,
|
|
2510
|
+
envelope: newEnvelope,
|
|
2511
|
+
projection,
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
finalizeTransactionLocked({ boardPath, claims: nextClaims, projection });
|
|
2515
|
+
return {
|
|
2516
|
+
ok: true,
|
|
2517
|
+
replaced: true,
|
|
2518
|
+
cardId,
|
|
2519
|
+
oldEnvelopeId: envelopeId,
|
|
2520
|
+
oldReason: reason,
|
|
2521
|
+
claim: newClaim,
|
|
2522
|
+
envelope: newEnvelope,
|
|
2523
|
+
projection,
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2189
2527
|
// ---------------------------------------------------------------------------
|
|
2190
2528
|
// Provider observation (§5 reversibility): everything registers behind the
|
|
2191
2529
|
// observation that a board file exists. No board file, no behavior change and
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
# Pulse
|
|
5
|
+
|
|
6
|
+
Board Pulse moves authorised Kanban work through available role/model capacity
|
|
7
|
+
with little ceremony. Use this skill when the user asks to "check the board",
|
|
8
|
+
"work through the ready cards", or asks about board scheduling capacity.
|
|
9
|
+
|
|
10
|
+
Board Pulse is capacity scheduling. It is not worker liveness observation —
|
|
11
|
+
that is the separate Herdr `pulse`.
|
|
12
|
+
|
|
13
|
+
## When to use what
|
|
14
|
+
|
|
15
|
+
- "Check the board" → call `agentic_kanban_pulse` with `action: "check"`.
|
|
16
|
+
This is a pure observation: it reports card results, capacity, and what
|
|
17
|
+
could be proposed, and it claims nothing.
|
|
18
|
+
- "Work through the ready cards" → the interactive `run` action (not yet
|
|
19
|
+
implemented; report that it is not available in this rollout step).
|
|
20
|
+
- Enable/disable/configure Pulse → the policy actions (not yet implemented;
|
|
21
|
+
the board-bound automation policy is the only policy store).
|
|
22
|
+
|
|
23
|
+
## Reading a check result
|
|
24
|
+
|
|
25
|
+
The result is a closed scan shape:
|
|
26
|
+
|
|
27
|
+
- `cards[]`: one result per card — `READY_FOR_NEXT`, `BLOCKED`, `STALE`,
|
|
28
|
+
`DENIED`, or `REVIEW_REQUIRED` — with a short human reason. Card IDs and
|
|
29
|
+
hashes stay internal; speak in titles.
|
|
30
|
+
- `capacity[]`: per role/model route — configured ceiling, active claims,
|
|
31
|
+
free slots, and availability (`available`, `full`, `unauthenticated`,
|
|
32
|
+
`unavailable`, `unknown`).
|
|
33
|
+
- `proposedDispatches[]`: what the scheduler would propose next (titles,
|
|
34
|
+
roles, models, placement).
|
|
35
|
+
|
|
36
|
+
Pulse is off unless the board's automation policy enables it. Without it,
|
|
37
|
+
every card reports `REVIEW_REQUIRED` and nothing is proposed. A check never
|
|
38
|
+
claims work, never spawns workers, and never completes cards — completion
|
|
39
|
+
remains an explicit human board transition.
|