ace-wake 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +42 -0
- data/README.md +96 -0
- data/handbook/extensions/ace-wake/index.js +457 -0
- data/handbook/extensions/ace-wake/package.json +3 -0
- data/handbook/extensions/ace-wake/wake/ask-bridge.js +102 -0
- data/handbook/extensions/ace-wake/wake/eyes-runtime.js +147 -0
- data/handbook/extensions/ace-wake/wake/eyes.js +152 -0
- data/handbook/extensions/ace-wake/wake/inbox-discovery.js +111 -0
- data/handbook/extensions/ace-wake/wake/loop-subscription.js +62 -0
- data/handbook/extensions/ace-wake/wake/types.js +112 -0
- data/handbook/extensions/ace-wake/wake/wake-command-parser.js +169 -0
- data/handbook/extensions/ace-wake/wake/wake-dispatcher.js +86 -0
- data/handbook/extensions/ace-wake/wake/wake-registry.js +772 -0
- data/handbook/extensions/ace-wake/wake/watch-subscription.js +984 -0
- data/lib/ace/wake/version.rb +7 -0
- data/lib/ace/wake.rb +25 -0
- metadata +62 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import * as nodeFs from "node:fs";
|
|
2
|
+
import * as nodePath from "node:path";
|
|
3
|
+
|
|
4
|
+
// Contract with the ace-hitl CLI (`ace-hitl ask`): the asker's Ruby process
|
|
5
|
+
// drops one request file per asked case into
|
|
6
|
+
// `<cwd>/.ace-local/hitl/wake-requests/<case id>.json`; this bridge consumes
|
|
7
|
+
// the folder through the extension's own reconcile pass and arms one scoped
|
|
8
|
+
// watch on the case file. When the answer lands, the case file's fingerprint
|
|
9
|
+
// changes and the wake carries the answer content (the excerpt prefers the
|
|
10
|
+
// `## Answer` section). The request file is consumed once the watch is armed;
|
|
11
|
+
// the armed watch itself persists through the registry (reload/restart safe),
|
|
12
|
+
// and nothing expires on the asker side.
|
|
13
|
+
export const ASK_REQUEST_SCHEMA = "ace.wake.ask/v1";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The wake-request folder for one agent session.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} cwd Session working directory (the asking agent's worktree).
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function requestsDirFor(cwd) {
|
|
22
|
+
return nodePath.join(cwd, ".ace-local", "hitl", "wake-requests");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Arm one scoped `ask-<case id>` watch per pending wake request. Idempotent:
|
|
27
|
+
* a request whose watch is already armed (a previous pass, a reload, or a
|
|
28
|
+
* restart re-registration) is consumed without re-arming, because a restart
|
|
29
|
+
* of a watch would reset its baseline fingerprint and could swallow an
|
|
30
|
+
* answer that already landed. A request whose case file is unreadable stays
|
|
31
|
+
* pending for the next pass. Torn or partial request files (never produced
|
|
32
|
+
* by the atomic ask writer, but never trusted either) stay untouched.
|
|
33
|
+
*
|
|
34
|
+
* @param {{requestsDir: string, registry: import("./wake-registry.js").WakeRegistry}} inputs
|
|
35
|
+
* @returns {number} Number of watches newly armed.
|
|
36
|
+
*/
|
|
37
|
+
export function armPendingAskWatches({ requestsDir, registry }) {
|
|
38
|
+
let entries;
|
|
39
|
+
try {
|
|
40
|
+
entries = nodeFs.readdirSync(requestsDir);
|
|
41
|
+
} catch {
|
|
42
|
+
// No requests folder (or unreadable): nothing to arm.
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
let armed = 0;
|
|
46
|
+
for (const name of entries.filter((entry) => entry.endsWith(".json")).sort()) {
|
|
47
|
+
const requestPath = nodePath.join(requestsDir, name);
|
|
48
|
+
let request;
|
|
49
|
+
try {
|
|
50
|
+
request = JSON.parse(nodeFs.readFileSync(requestPath, "utf8"));
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!isAskRequest(request)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const watchName = `ask-${request.case_id}`;
|
|
58
|
+
if (registry.list().some((entry) => entry.kind === "watch" && entry.name === watchName)) {
|
|
59
|
+
consumeRequest(requestPath);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
registry.addWatch({
|
|
64
|
+
name: watchName,
|
|
65
|
+
path: request.case_path,
|
|
66
|
+
message: request.secret
|
|
67
|
+
? `HITL answer for case ${request.case_id}: the value was delivered to the case file; read it through the secure channel (never this wake)`
|
|
68
|
+
: `HITL answer for case ${request.case_id}: run /continue-work only when this wake names the answer`,
|
|
69
|
+
redact: request.secret === true,
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
// The case file is unreadable or the watch is invalid: keep the
|
|
73
|
+
// request pending for the next reconcile pass.
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
consumeRequest(requestPath);
|
|
77
|
+
armed += 1;
|
|
78
|
+
}
|
|
79
|
+
return armed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} path
|
|
84
|
+
*/
|
|
85
|
+
function consumeRequest(path) {
|
|
86
|
+
try {
|
|
87
|
+
nodeFs.unlinkSync(path);
|
|
88
|
+
} catch {
|
|
89
|
+
// Already consumed by a concurrent pass.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {unknown} request
|
|
95
|
+
* @returns {request is {schema: string, kind: string, case_id: string, case_path: string}}
|
|
96
|
+
*/
|
|
97
|
+
function isAskRequest(request) {
|
|
98
|
+
return typeof request === "object" && request !== null &&
|
|
99
|
+
request.schema === ASK_REQUEST_SCHEMA && request.kind === "ask" &&
|
|
100
|
+
typeof request.case_id === "string" && request.case_id.length > 0 &&
|
|
101
|
+
typeof request.case_path === "string" && request.case_path.length > 0;
|
|
102
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { Eyes, EYES_ENTRY } from "./eyes.js";
|
|
6
|
+
|
|
7
|
+
const execute = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
/** Existing native sync owns canonical import; HITL owns event projection.
|
|
10
|
+
* No Ruby parsing, private text, shell command strings or model work here. */
|
|
11
|
+
export async function observeHitl(cwd, config) {
|
|
12
|
+
const options = { cwd, timeout: config.timeoutMs, maxBuffer: 1024 * 1024, encoding: "utf8" };
|
|
13
|
+
for (const source of config.syncRoutes) {
|
|
14
|
+
// One source per project: each entry names the project hitl config and,
|
|
15
|
+
// when several hermes_native routes exist there, the route to reconcile.
|
|
16
|
+
const argv = ["sync", "--config", source.config];
|
|
17
|
+
if (source.route !== undefined) argv.push("--route", source.route);
|
|
18
|
+
const { stdout } = await execute("ace-hitl-hermes", argv, options);
|
|
19
|
+
const result = JSON.parse(stdout);
|
|
20
|
+
if (!Array.isArray(result.errors) || result.errors.length) throw new Error("Native synchronization incomplete");
|
|
21
|
+
}
|
|
22
|
+
const { stdout } = await execute("ace-overseer", ["observe", "--owner", config.recipient, "--scope", config.scope, "--limit", String(config.maxEvents)], options);
|
|
23
|
+
return JSON.parse(stdout);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function registerEyes(pi, { observe = observeHitl, now = Date.now,
|
|
27
|
+
schedule = (fn, ms) => setInterval(fn, ms), cancel = clearInterval } = {}) {
|
|
28
|
+
let eyes, timer, attempt, compacting = false, running = false;
|
|
29
|
+
const stopTimer = () => { if (timer !== undefined) cancel(timer); timer = undefined; };
|
|
30
|
+
function arm() {
|
|
31
|
+
stopTimer();
|
|
32
|
+
if (eyes?.s.enabled && !eyes.s.retired && !eyes.s.breaker) {
|
|
33
|
+
timer = schedule(() => { void eyes.tick(); }, eyes.s.config.intervalSeconds * 1000);
|
|
34
|
+
timer?.unref?.();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const bind = (ctx) => {
|
|
38
|
+
stopTimer(); eyes?.dispose(); attempt = undefined; compacting = false; running = false;
|
|
39
|
+
const entries = ctx.sessionManager.getBranch();
|
|
40
|
+
const snapshot = [...entries].reverse().find((e) => e.type === "custom" && e.customType === EYES_ENTRY)?.data;
|
|
41
|
+
const ownerCwd = path.resolve(ctx.cwd);
|
|
42
|
+
if (snapshot?.ownerCwd && snapshot.ownerCwd !== ownerCwd) {
|
|
43
|
+
eyes = undefined;
|
|
44
|
+
ctx.ui?.setStatus?.("ace-eyes", "eyes blocked: session belongs to another project cwd");
|
|
45
|
+
throw new Error("Eyes project owner mismatch; preserve this actor and use its original project session");
|
|
46
|
+
}
|
|
47
|
+
eyes = new Eyes({ snapshot, now,
|
|
48
|
+
save: (data) => pi.appendEntry(EYES_ENTRY, { ...data, ownerCwd }),
|
|
49
|
+
status: (state) => {
|
|
50
|
+
ctx.ui?.setStatus?.("ace-eyes", `eyes ${state.retired ? "retired" : state.breaker?.reason || (state.enabled ? "observing" : "off")} pending:${state.pending}`);
|
|
51
|
+
if (state.breaker || state.retired) stopTimer();
|
|
52
|
+
},
|
|
53
|
+
observe: (config) => observe(ctx.cwd, config),
|
|
54
|
+
canDeliver: () => !compacting && (ctx.isIdle() || running),
|
|
55
|
+
deliveryStatus: () => {
|
|
56
|
+
if (attempt?.accepted) return "accepted";
|
|
57
|
+
// sendUserMessage is a void wrapper. Busy/queued is uncertain, not
|
|
58
|
+
// acceptance or rejection. Idle without confirmation is uncertain:
|
|
59
|
+
// async input/auth preflight may still be running, so never blind-retry.
|
|
60
|
+
return compacting || !ctx.isIdle() || ctx.hasPendingMessages() ? "waiting" : "uncertain";
|
|
61
|
+
},
|
|
62
|
+
deliver: async (batch) => {
|
|
63
|
+
if (!ctx.model) return false;
|
|
64
|
+
const text = `[ace-eyes] Pending canonical HITL/deadline revisions: ${JSON.stringify(batch)}. ` +
|
|
65
|
+
"Read original events; adopt reports through ace-overseer process-reports and existing task/assignment gates. " +
|
|
66
|
+
"Reconcile uncertain effects; never repeat external actions on wake alone. After handling each exact revision, " +
|
|
67
|
+
"acknowledge with the ace_eyes tool (action ack, key, revision). Settlement alone is not acknowledgement.";
|
|
68
|
+
attempt = { text, accepted: false };
|
|
69
|
+
const result = pi.sendUserMessage(text, { deliverAs: "followUp" });
|
|
70
|
+
// Some hosts expose a promise, the standard ExtensionAPI exposes void.
|
|
71
|
+
// Neither completion nor void is proof of message consumption.
|
|
72
|
+
if (result?.then) await result;
|
|
73
|
+
return result !== false;
|
|
74
|
+
} });
|
|
75
|
+
arm();
|
|
76
|
+
if (eyes.s.enabled) void eyes.tick();
|
|
77
|
+
};
|
|
78
|
+
pi.on("session_start", async (_event, ctx) => bind(ctx));
|
|
79
|
+
pi.on("session_tree", async (_event, ctx) => bind(ctx));
|
|
80
|
+
pi.on("agent_start", async () => { running = true; });
|
|
81
|
+
pi.on("agent_settled", async () => { running = false; });
|
|
82
|
+
pi.on("message_start", async (event) => {
|
|
83
|
+
if (event.message?.role !== "user" || !attempt) return;
|
|
84
|
+
const text = typeof event.message.content === "string" ? event.message.content :
|
|
85
|
+
(event.message.content || []).filter(part => part.type === "text").map(part => part.text).join("");
|
|
86
|
+
if (text === attempt.text) attempt.accepted = true;
|
|
87
|
+
});
|
|
88
|
+
pi.on("session_before_compact", async (event) => { if (event.reason === "manual") compacting = true; });
|
|
89
|
+
pi.on("session_compact", async () => { compacting = false; eyes?.persist(); });
|
|
90
|
+
pi.on("session_compact_failed", async () => { compacting = false; });
|
|
91
|
+
pi.on("session_shutdown", async () => { stopTimer(); eyes?.dispose(); });
|
|
92
|
+
pi.on("message_end", async (event) => {
|
|
93
|
+
const message = event.message;
|
|
94
|
+
if (message?.role === "assistant" && message.stopReason === "error") eyes?.quota(message.errorMessage);
|
|
95
|
+
});
|
|
96
|
+
pi.registerTool?.({
|
|
97
|
+
name: "ace_eyes", label: "Eyes acknowledgement", description: "Read pending semantic events or acknowledge an exact handled revision. Does not accept reports, authorize effects, start actors or reset quota. Use existing report/assignment owners first. Deadlines require an operation identity and revision; cancel when no longer active.",
|
|
98
|
+
parameters: { type: "object", properties: {
|
|
99
|
+
action: { type: "string", enum: ["status", "ack", "deadline", "cancel"] },
|
|
100
|
+
key: { type: "string" }, revision: { type: "string" }, at: { type: "integer" }
|
|
101
|
+
}, required: ["action"], additionalProperties: false },
|
|
102
|
+
execute: async (_id, args, _signal, _onUpdate, ctx) => {
|
|
103
|
+
if (!eyes) {
|
|
104
|
+
if (!ctx) throw new Error("Eyes session not initialized");
|
|
105
|
+
bind(ctx);
|
|
106
|
+
}
|
|
107
|
+
if (args.action === "ack") eyes.ack(args.key, args.revision);
|
|
108
|
+
else if (args.action === "deadline") eyes.deadline(args.key, args.at, args.revision);
|
|
109
|
+
else if (args.action === "cancel") eyes.cancelDeadline(args.key);
|
|
110
|
+
else if (args.action !== "status") throw new Error("Unknown eyes action");
|
|
111
|
+
// Do not send a second wake from inside the running model turn. The next
|
|
112
|
+
// observation delivers remaining coalesced revisions through the same owner.
|
|
113
|
+
const details = { ...eyes.summary(), pending: eyes.s.pending, deadlines: eyes.s.deadlines };
|
|
114
|
+
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
pi.registerCommand("eyes", { description: "Event-driven HITL observation: start CONFIG | status | ack KEY REV | deadline NAME REVISION EPOCH_MS | cancel NAME | resume quota-verified | retire",
|
|
118
|
+
handler: async (args, ctx) => {
|
|
119
|
+
try {
|
|
120
|
+
if (!eyes) bind(ctx);
|
|
121
|
+
const [command, a, b, ...rest] = args.trim().split(/\s+/);
|
|
122
|
+
if (rest.length && command !== "deadline") throw new Error("Too many arguments");
|
|
123
|
+
switch (command) {
|
|
124
|
+
case "start": {
|
|
125
|
+
if (!a || b) throw new Error("Use eyes start CONFIG_JSON");
|
|
126
|
+
const config = JSON.parse(fs.readFileSync(path.resolve(ctx.cwd, a), "utf8"));
|
|
127
|
+
eyes.start(config); arm(); await eyes.tick(); break;
|
|
128
|
+
}
|
|
129
|
+
case "status": ctx.ui?.notify?.(JSON.stringify({ ...eyes.summary(), pending: eyes.s.pending, deadlines: eyes.s.deadlines }), "info"); break;
|
|
130
|
+
case "ack": eyes.ack(a, b); await eyes.tick(); break;
|
|
131
|
+
case "deadline":
|
|
132
|
+
if (rest.length !== 1) throw new Error("Deadline requires operation identity, revision and epoch milliseconds");
|
|
133
|
+
eyes.deadline(a, Number(rest[0]), b); await eyes.tick(); break;
|
|
134
|
+
case "cancel": eyes.cancelDeadline(a); break;
|
|
135
|
+
case "resume":
|
|
136
|
+
if (a !== "quota-verified" || b) throw new Error("Explicit owner verification required: eyes resume quota-verified");
|
|
137
|
+
await eyes.resume(); arm(); break;
|
|
138
|
+
case "retire": eyes.retire(); stopTimer(); break;
|
|
139
|
+
default: throw new Error("Unknown eyes command; use start, status, ack, deadline, cancel, resume or retire");
|
|
140
|
+
}
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// Never echo process stdout/stderr: a failed native sync may carry private data.
|
|
143
|
+
ctx.ui?.notify?.(`Eyes operation refused (${error.name}); check configuration or /eyes status`, "error");
|
|
144
|
+
}
|
|
145
|
+
} });
|
|
146
|
+
return { active: () => Boolean(eyes?.s.enabled || eyes?.s.retired) };
|
|
147
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
4
|
+
export const EYES_ENTRY = "ace-wake-eyes";
|
|
5
|
+
export const defaults = { intervalSeconds: 30, maxEvents: 500, maxObservationsPerHour: 120, maxWakesPerHour: 20,
|
|
6
|
+
timeoutMs: 10000, maxErrors: 3, maxPending: 2000, scope: "current", recipient: "self", syncRoutes: [] };
|
|
7
|
+
|
|
8
|
+
export function validateConfig(input) {
|
|
9
|
+
if (!input || typeof input !== "object" || Array.isArray(input) ||
|
|
10
|
+
Object.keys(input).some(key => !(key in defaults))) throw new Error("Invalid eyes configuration");
|
|
11
|
+
const config = { ...defaults, ...input };
|
|
12
|
+
for (const [key, min, max] of [["intervalSeconds", 1, 3600], ["maxEvents", 1, 2000],
|
|
13
|
+
["maxObservationsPerHour", 1, 3600], ["maxWakesPerHour", 1, 1000], ["timeoutMs", 100, 30000], ["maxErrors", 1, 10], ["maxPending", 1, 10000]]) {
|
|
14
|
+
if (!Number.isInteger(config[key]) || config[key] < min || config[key] > max) throw new Error(`Invalid ${key}`);
|
|
15
|
+
}
|
|
16
|
+
if (typeof config.recipient !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(config.recipient)) throw new Error("Invalid observer recipient");
|
|
17
|
+
const validSource = (p) => typeof p === "object" && p !== null && typeof p.config === "string" && p.config.startsWith("/") &&
|
|
18
|
+
(p.route === undefined || (typeof p.route === "string" && /^[a-zA-Z0-9_-]{1,64}$/.test(p.route)));
|
|
19
|
+
if (!["current", "all"].includes(config.scope) || !Array.isArray(config.syncRoutes) || config.syncRoutes.length > 10 ||
|
|
20
|
+
config.syncRoutes.some((p) => !validSource(p))) throw new Error("Invalid eyes sources");
|
|
21
|
+
return config;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Observation/delivery bookkeeping in the existing Pi session, not a work
|
|
25
|
+
* queue. It never starts tasks or accepts reports on behalf of their owner. */
|
|
26
|
+
export class Eyes {
|
|
27
|
+
constructor({ snapshot, save, observe, deliver, now = Date.now, status = () => {}, deliveryStatus = () => "accepted", canDeliver = () => true }) {
|
|
28
|
+
this.s = structuredClone(snapshot || { enabled: false, retired: false, config: defaults,
|
|
29
|
+
pending: {}, acknowledged: {}, deadlines: {}, breaker: null, errors: 0, window: null });
|
|
30
|
+
this.s.config = validateConfig(this.s.config);
|
|
31
|
+
for (const key of ["pending", "acknowledged", "deadlines"])
|
|
32
|
+
if (!this.s[key] || typeof this.s[key] !== "object" || Array.isArray(this.s[key])) throw new Error("Invalid persisted eyes state");
|
|
33
|
+
if (typeof this.s.enabled !== "boolean" || typeof this.s.retired !== "boolean" ||
|
|
34
|
+
!Number.isInteger(this.s.errors) || this.s.errors < 0) throw new Error("Invalid persisted eyes state");
|
|
35
|
+
this.save = save; this.observe = observe; this.deliver = deliver; this.now = now; this.status = status;
|
|
36
|
+
this.deliveryStatus = deliveryStatus; this.canDeliver = canDeliver;
|
|
37
|
+
this.busy = false; this.disposed = false;
|
|
38
|
+
// A process restart does not imply receipt acknowledgement. Reconcile the
|
|
39
|
+
// same pending revisions; business-effect idempotence remains with .5.4.
|
|
40
|
+
this.inflight = false;
|
|
41
|
+
this.sent = new Map();
|
|
42
|
+
this.generation = 0;
|
|
43
|
+
}
|
|
44
|
+
persist() { this.save(structuredClone(this.s)); this.status(this.summary()); }
|
|
45
|
+
summary() { return { enabled: this.s.enabled, retired: this.s.retired, pending: Object.keys(this.s.pending).length,
|
|
46
|
+
breaker: this.s.breaker, errors: this.s.errors, observations: this.s.window?.count || 0, wakes: this.s.window?.wakes || 0 }; }
|
|
47
|
+
start(config) {
|
|
48
|
+
if (this.s.retired) throw new Error("Retired actor cannot restart its eyes; hand off pending to its owner");
|
|
49
|
+
if (this.s.breaker) throw new Error("Resolve breaker with explicit resume first");
|
|
50
|
+
this.s.config = validateConfig(config); this.s.enabled = true; this.persist();
|
|
51
|
+
}
|
|
52
|
+
block(reason) {
|
|
53
|
+
if (!this.s.breaker) { this.s.breaker = { reason, at: this.now() }; this.persist(); }
|
|
54
|
+
}
|
|
55
|
+
async resume() {
|
|
56
|
+
if (this.s.retired) throw new Error("Retired actor cannot resume");
|
|
57
|
+
// Explicit operator action; check source/transport readiness without a model
|
|
58
|
+
// request. Quota availability must have been verified by the owner.
|
|
59
|
+
const result = await this.observe(this.s.config);
|
|
60
|
+
this.validate(result);
|
|
61
|
+
this.s.breaker = null; this.s.errors = 0; this.s.window = null; this.inflight = false;
|
|
62
|
+
this.s.enabled = true; this.persist();
|
|
63
|
+
await this.tick();
|
|
64
|
+
}
|
|
65
|
+
retire() { this.generation++; this.s.enabled = false; this.s.retired = true; this.persist(); }
|
|
66
|
+
dispose() { this.disposed = true; this.generation++; }
|
|
67
|
+
quota(message) {
|
|
68
|
+
if (/quota|insufficient.?(?:credit|balance)|billing.?limit|budget.?exceed|usage.?limit|rate.?limit|\b429\b/i.test(String(message))) this.block("quota-or-budget");
|
|
69
|
+
}
|
|
70
|
+
deadline(name, at, operationRevision) {
|
|
71
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name) || !Number.isSafeInteger(at) || at < 0 || typeof operationRevision !== "string" || !/^[a-zA-Z0-9._:-]{1,128}$/.test(operationRevision)) throw new Error("Invalid deadline");
|
|
72
|
+
const key = `deadline:${name}`;
|
|
73
|
+
delete this.s.pending[key];
|
|
74
|
+
this.sent.delete(key); this.inflight = this.sent.size > 0;
|
|
75
|
+
this.s.deadlines[name] = { at, operationRevision }; this.persist();
|
|
76
|
+
}
|
|
77
|
+
cancelDeadline(name) {
|
|
78
|
+
delete this.s.deadlines[name]; delete this.s.pending[`deadline:${name}`];
|
|
79
|
+
this.sent.delete(`deadline:${name}`); this.inflight = this.sent.size > 0; this.persist();
|
|
80
|
+
}
|
|
81
|
+
ack(key, revision) {
|
|
82
|
+
if (this.s.pending[key]?.revision !== revision && this.sent.get(key) !== revision)
|
|
83
|
+
throw new Error("Ack does not match an observed pending or delivered revision");
|
|
84
|
+
this.s.acknowledged[key] = revision;
|
|
85
|
+
if (this.s.pending[key]?.revision === revision) delete this.s.pending[key];
|
|
86
|
+
this.sent.delete(key);
|
|
87
|
+
this.inflight = this.sent.size > 0; this.persist();
|
|
88
|
+
}
|
|
89
|
+
validate(result) {
|
|
90
|
+
if (result?.schema !== "ace.hitl.observation/v1" || !Array.isArray(result.events) || result.events.length > this.s.config.maxEvents)
|
|
91
|
+
throw new Error("Invalid or over-budget HITL observation");
|
|
92
|
+
if (result.settled !== undefined && !Array.isArray(result.settled)) throw new Error("Invalid settlement projection");
|
|
93
|
+
const rows = [...result.events, ...(result.settled || [])];
|
|
94
|
+
if (rows.length > this.s.config.maxEvents) throw new Error("Over-budget projection");
|
|
95
|
+
const keys = new Set();
|
|
96
|
+
for (const event of rows) {
|
|
97
|
+
if (!/^[a-f0-9]{64}$/.test(event.key) || !/^[a-f0-9]{64}$/.test(event.revision) ||
|
|
98
|
+
typeof event.id !== "string" || !/^[a-z0-9_-]{1,64}$/i.test(event.id) || keys.has(event.key)) throw new Error("Invalid event identity");
|
|
99
|
+
keys.add(event.key);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async tick() {
|
|
103
|
+
if (this.busy || this.disposed || !this.s.enabled || this.s.retired || this.s.breaker) return;
|
|
104
|
+
const now = this.now();
|
|
105
|
+
if (!this.s.window || now - this.s.window.start >= 3600000) this.s.window = { start: now, count: 0, wakes: 0 };
|
|
106
|
+
if (this.s.window.count >= this.s.config.maxObservationsPerHour) { this.block("observation-budget"); return; }
|
|
107
|
+
this.s.window.count++; this.persist(); // Budget survives restart too.
|
|
108
|
+
this.busy = true;
|
|
109
|
+
const generation = this.generation;
|
|
110
|
+
try {
|
|
111
|
+
const result = await this.observe(this.s.config);
|
|
112
|
+
if (generation !== this.generation || this.disposed) return;
|
|
113
|
+
this.validate(result);
|
|
114
|
+
for (const e of result.settled || []) {
|
|
115
|
+
this.s.acknowledged[e.key] = e.revision;
|
|
116
|
+
if (this.s.pending[e.key]?.revision === e.revision) delete this.s.pending[e.key];
|
|
117
|
+
this.sent.delete(e.key);
|
|
118
|
+
}
|
|
119
|
+
if (this.inflight && this.sent.size === 0) this.inflight = false;
|
|
120
|
+
for (const e of result.events) {
|
|
121
|
+
if (this.s.acknowledged[e.key] !== e.revision) this.s.pending[e.key] = { id: e.id, revision: e.revision };
|
|
122
|
+
}
|
|
123
|
+
for (const [name, deadline] of Object.entries(this.s.deadlines)) {
|
|
124
|
+
const { at, operationRevision } = deadline;
|
|
125
|
+
const key = `deadline:${name}`, revision = hash([name, at, operationRevision]);
|
|
126
|
+
if (now >= at && this.s.acknowledged[key] !== revision) this.s.pending[key] = { id: name, revision };
|
|
127
|
+
}
|
|
128
|
+
if (Object.keys(this.s.pending).length + Object.keys(this.s.acknowledged).length > this.s.config.maxPending) {
|
|
129
|
+
this.block("retention-budget"); return;
|
|
130
|
+
}
|
|
131
|
+
this.persist();
|
|
132
|
+
if (this.inflight && this.deliveryStatus() === "uncertain") {
|
|
133
|
+
this.block("notification-unconfirmed"); return;
|
|
134
|
+
}
|
|
135
|
+
if (!this.inflight && !this.s.breaker && Object.keys(this.s.pending).length && this.canDeliver()) {
|
|
136
|
+
// Retain identities before attempting delivery. A rejected send leaves
|
|
137
|
+
// pending intact; acceptance by Pi is not application acknowledgement.
|
|
138
|
+
if ((this.s.window.wakes || 0) >= this.s.config.maxWakesPerHour) { this.block("wake-budget"); return; }
|
|
139
|
+
this.s.window.wakes = (this.s.window.wakes || 0) + 1;
|
|
140
|
+
this.persist(); // Includes uncertain notification attempts, never token billing.
|
|
141
|
+
const batch = Object.entries(this.s.pending).slice(0, 20);
|
|
142
|
+
this.inflight = Boolean(await this.deliver(batch));
|
|
143
|
+
if (this.inflight) this.sent = new Map(batch.map(([key, value]) => [key, value.revision]));
|
|
144
|
+
if (!this.inflight) throw new Error("wake delivery refused");
|
|
145
|
+
}
|
|
146
|
+
if (this.s.errors && (!this.inflight || this.deliveryStatus() === "accepted")) { this.s.errors = 0; this.persist(); }
|
|
147
|
+
} catch {
|
|
148
|
+
this.s.errors++; this.persist();
|
|
149
|
+
if (this.s.errors >= this.s.config.maxErrors) this.block("observation-or-delivery-failure");
|
|
150
|
+
} finally { this.busy = false; }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
3
|
+
import * as nodePath from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cross-worktree discovery for the ace-hitl report inbox.
|
|
7
|
+
*
|
|
8
|
+
* `ace-hitl create` writes a coworker's report inside THAT coworker's
|
|
9
|
+
* worktree (its own `.ace-local/hitl`), while the overseer session usually
|
|
10
|
+
* waits in the main checkout — a watch on the main checkout's inbox alone
|
|
11
|
+
* never fires. The HITL list already solves this with multi-worktree
|
|
12
|
+
* discovery over `git worktree list --porcelain`; this module reuses the
|
|
13
|
+
* SAME discovery (no second inbox, queue, or cache) to expand a configured
|
|
14
|
+
* `.ace-local/hitl` watch to every linked worktree's inbox directory.
|
|
15
|
+
*
|
|
16
|
+
* Expansion happens at watch registration, at every re-registration
|
|
17
|
+
* (session start/reload/branch switch), and on every watch refresh pass,
|
|
18
|
+
* so worktrees linked while the session is already watching are picked
|
|
19
|
+
* up without waiting for a re-registration.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The inbox path suffix that marks a watch as the ace-hitl report inbox. */
|
|
23
|
+
export const HITL_INBOX_SEGMENTS = [".ace-local", "hitl"];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* List the roots of every linked worktree (including the main checkout)
|
|
27
|
+
* using the same porcelain output ace-hitl's WorktreeScopeResolver reads.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} baseDir session working directory (git runs there)
|
|
30
|
+
* @param {(args: string[], options: object) => string} [execGit] injectable
|
|
31
|
+
* git runner for tests; defaults to execFileSync with output decoding
|
|
32
|
+
* @returns {string[]} unique worktree roots, main first; [] when git fails
|
|
33
|
+
*/
|
|
34
|
+
export function discoverWorktreeRoots(baseDir, execGit = defaultExecGit) {
|
|
35
|
+
let output;
|
|
36
|
+
try {
|
|
37
|
+
output = execGit(["worktree", "list", "--porcelain"], { cwd: baseDir });
|
|
38
|
+
} catch {
|
|
39
|
+
// Not a repository (or git missing): the caller keeps whatever targets
|
|
40
|
+
// it can resolve on its own.
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
return output
|
|
44
|
+
.split(/\r?\n/)
|
|
45
|
+
.filter((line) => line.startsWith("worktree "))
|
|
46
|
+
.map((line) => line.slice("worktree ".length).trim())
|
|
47
|
+
.filter((path) => path.length > 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the watch targets for a configured path. A path that names an
|
|
52
|
+
* ace-hitl inbox (any `.ace-local/hitl` directory) expands to that same
|
|
53
|
+
* inbox in EVERY discovered worktree, so a report written inside a
|
|
54
|
+
* coworker's worktree wakes the waiting session. Any other path stays a
|
|
55
|
+
* single target — the watch never invents unrelated directories.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} baseDir session working directory
|
|
58
|
+
* @param {string} configuredPath the path the user configured
|
|
59
|
+
* @param {(args: string[], options: object) => string} [execGit]
|
|
60
|
+
* @returns {string[]} canonical target directories, sorted and unique
|
|
61
|
+
*/
|
|
62
|
+
export function expandInboxWatchTargets(baseDir, configuredPath, execGit = defaultExecGit) {
|
|
63
|
+
const primary = realpathOrSelf(nodePath.resolve(baseDir, configuredPath));
|
|
64
|
+
if (!namesInbox(primary)) {
|
|
65
|
+
return [primary];
|
|
66
|
+
}
|
|
67
|
+
const roots = discoverWorktreeRoots(baseDir, execGit);
|
|
68
|
+
if (roots.length === 0) {
|
|
69
|
+
return [primary];
|
|
70
|
+
}
|
|
71
|
+
// Realpath the (existing) worktree ROOT, not the not-yet-existing inbox:
|
|
72
|
+
// /tmp → /private/tmp style symlinked roots would otherwise survive as
|
|
73
|
+
// separate strings and defeat dedup against the canonicalized primary.
|
|
74
|
+
const targets = new Set(roots.map((root) => nodePath.join(realpathOrSelf(root), ...HITL_INBOX_SEGMENTS)));
|
|
75
|
+
targets.add(primary);
|
|
76
|
+
return [...targets].sort();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} path
|
|
81
|
+
* @returns {string} The realpath when the path exists, the path itself otherwise.
|
|
82
|
+
*/
|
|
83
|
+
function realpathOrSelf(path) {
|
|
84
|
+
try {
|
|
85
|
+
return nodeFs.realpathSync(path);
|
|
86
|
+
} catch {
|
|
87
|
+
return path;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {string} path @returns {boolean} */
|
|
92
|
+
function namesInbox(path) {
|
|
93
|
+
const segments = nodePath.normalize(path).split(nodePath.sep);
|
|
94
|
+
const last = HITL_INBOX_SEGMENTS.length;
|
|
95
|
+
for (let index = segments.length - last; index >= 0; index -= 1) {
|
|
96
|
+
const window = segments.slice(index, index + last);
|
|
97
|
+
if (window.join(nodePath.sep) === HITL_INBOX_SEGMENTS.join(nodePath.sep)) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {string[]} args
|
|
106
|
+
* @param {{cwd: string}} options
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
function defaultExecGit(args, options) {
|
|
110
|
+
return execFileSync("git", args, { ...options, encoding: "utf8" });
|
|
111
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop subscription port owning native interval handles.
|
|
3
|
+
*
|
|
4
|
+
* Each start schedules an in-process interval from "now". After disposal and
|
|
5
|
+
* re-registration (reload, restart, reconcile) the next tick is a full
|
|
6
|
+
* interval away: no missed or catch-up ticks are ever replayed.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} ports
|
|
9
|
+
* @param {(callback: () => void, ms: number) => unknown} ports.setIntervalFn
|
|
10
|
+
* @param {(handle: unknown) => void} ports.clearIntervalFn
|
|
11
|
+
* @returns {{
|
|
12
|
+
* start: (definition: import("./types.js").LoopDefinition, onTick: (definition: import("./types.js").LoopDefinition) => void) => void,
|
|
13
|
+
* stop: (name: string) => void,
|
|
14
|
+
* stopAll: () => void,
|
|
15
|
+
* errorOf: (name: string) => string | undefined,
|
|
16
|
+
* activeCount: () => number,
|
|
17
|
+
* }}
|
|
18
|
+
*/
|
|
19
|
+
export function createLoopPort({ setIntervalFn, clearIntervalFn }) {
|
|
20
|
+
/** @type {Map<string, unknown>} */
|
|
21
|
+
const handles = new Map();
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
start(definition, onTick) {
|
|
25
|
+
stopExisting(definition.name);
|
|
26
|
+
const milliseconds = definition.intervalSeconds * 1000;
|
|
27
|
+
const handle = setIntervalFn(() => onTick(definition), milliseconds);
|
|
28
|
+
// Never hold the host process open on the extension's behalf.
|
|
29
|
+
handle?.unref?.();
|
|
30
|
+
handles.set(definition.name, handle);
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
stop(name) {
|
|
34
|
+
stopExisting(name);
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
stopAll() {
|
|
38
|
+
for (const handle of handles.values()) {
|
|
39
|
+
clearIntervalFn(handle);
|
|
40
|
+
}
|
|
41
|
+
handles.clear();
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
errorOf() {
|
|
45
|
+
// Intervals do not produce runtime errors; the clock either runs or the
|
|
46
|
+
// process is gone.
|
|
47
|
+
return undefined;
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
activeCount() {
|
|
51
|
+
return handles.size;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function stopExisting(name) {
|
|
56
|
+
const handle = handles.get(name);
|
|
57
|
+
if (handle !== undefined) {
|
|
58
|
+
clearIntervalFn(handle);
|
|
59
|
+
handles.delete(name);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|