agent-yes 1.228.0 → 1.229.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/dist/{JsonlStore-Bm5Nj6v_.js → JsonlStore-CWOA3aDH.js} +1 -1
- package/dist/{SUPPORTED_CLIS-C5d64Xqj.js → SUPPORTED_CLIS-CvLtp6LS.js} +2 -2
- package/dist/SUPPORTED_CLIS-DJEnqsp2.js +11 -0
- package/dist/{agentShare-CpglhRYb.js → agentShare-CQwvWWfd.js} +4 -4
- package/dist/{callback-B3N2VJ1i.js → callback-BFZDARQq.js} +4 -4
- package/dist/{callback-CCEQtwXX.js → callback-BN-tH9dB.js} +3 -3
- package/dist/cli.js +9 -9
- package/dist/{cwdConflictWarn-CJZRp4S0.js → cwdConflictWarn-CKUsQ5_o.js} +2 -2
- package/dist/{globalPidIndex-CoNr7tS8.js → globalPidIndex-Ca4sTr1b.js} +1 -1
- package/dist/index.js +8 -8
- package/dist/{messageLog-Bb_Ie3Ck.js → messageLog-zVGWLX7w.js} +1 -1
- package/dist/{notifyDaemon-CfQN5blR.js → notifyDaemon-CENKHs1M.js} +4 -4
- package/dist/pidStore-Cp3HekUh.js +6 -0
- package/dist/{pidStore-DCqpU_bI.js → pidStore-Hjba3K6Q.js} +3 -3
- package/dist/{reaper-BNKvujYy.js → reaper-iQXyufgp.js} +1 -1
- package/dist/{reaper-BBc2AIKA.js → reaper-pJpMYKmj.js} +1 -1
- package/dist/{runningLock-CNMl13dC.js → runningLock-BFk3Mo8K.js} +1 -1
- package/dist/{rustBinary-B8IcqSw1.js → rustBinary-BjApGser.js} +2 -2
- package/dist/{schedule-B-RjgOOR.js → schedule-D0O3sb5A.js} +9 -9
- package/dist/{serve-DwF03sgB.js → serve-m-WChRrO.js} +67 -21
- package/dist/{setup-cZFxfS89.js → setup-fuwC5wlF.js} +2 -2
- package/dist/spawnGate-5jC6eiqk.js +5 -0
- package/dist/{spawnGate-CeFYH-Ib.js → spawnGate-CMlGqVN4.js} +2 -2
- package/dist/{subcommands-DSyCT0vl.js → subcommands-DzJbnYAx.js} +12 -12
- package/dist/{subcommands-UseeaeYM.js → subcommands-lwgJxJex.js} +3 -3
- package/dist/{todoAutomation-Cwnh4aWo.js → todoAutomation-x6guaQXc.js} +115 -6
- package/dist/{todoCli-6v1CYdxN.js → todoCli-Cjhx3DqQ.js} +38 -6
- package/dist/{tray-DBt8X-Qj.js → tray-C8VfnS7s.js} +2 -2
- package/dist/{ts-Cl3CmLH9.js → ts-oYrTk4Z_.js} +103 -20
- package/dist/{versionChecker-Dhjyjxs_.js → versionChecker-CgCXc8CJ.js} +2 -2
- package/dist/{ws-Dw2fGxTI.js → ws-D5q3R1kY.js} +4 -4
- package/package.json +1 -1
- package/ts/askApi.spec.ts +358 -0
- package/ts/askApi.ts +255 -0
- package/ts/index.ts +2 -0
- package/ts/serve.ts +131 -0
- package/ts/todoBlock.spec.ts +10 -0
- package/ts/todoBlock.ts +14 -2
- package/ts/todoCli.spec.ts +101 -0
- package/ts/todoCli.ts +69 -1
- package/ts/todoStore.spec.ts +126 -0
- package/ts/todoStore.ts +130 -2
- package/dist/SUPPORTED_CLIS-Bfresz1n.js +0 -11
- package/dist/pidStore-CatWvn5W.js +0 -6
- package/dist/spawnGate-CIC7b7nm.js +0 -5
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { rm, mkdir } from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { listAsks, listAsksForProject, answerAsk, hasTodoStore } from "./askApi";
|
|
5
|
+
import { TodoStore, openStore } from "./todoStore";
|
|
6
|
+
import type { TodoBlock } from "./todoBlock";
|
|
7
|
+
|
|
8
|
+
const isWindows = process.platform === "win32";
|
|
9
|
+
const ROOT_A = isWindows
|
|
10
|
+
? path.join(process.env.TEMP || "C:\\Temp", "askapi-a-" + process.pid)
|
|
11
|
+
: "/tmp/askapi-a-" + process.pid;
|
|
12
|
+
const ROOT_B = isWindows
|
|
13
|
+
? path.join(process.env.TEMP || "C:\\Temp", "askapi-b-" + process.pid)
|
|
14
|
+
: "/tmp/askapi-b-" + process.pid;
|
|
15
|
+
const ROOT_NONE = isWindows
|
|
16
|
+
? path.join(process.env.TEMP || "C:\\Temp", "askapi-none-" + process.pid)
|
|
17
|
+
: "/tmp/askapi-none-" + process.pid;
|
|
18
|
+
|
|
19
|
+
describe("askApi", () => {
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
await rm(ROOT_A, { recursive: true, force: true });
|
|
22
|
+
await rm(ROOT_B, { recursive: true, force: true });
|
|
23
|
+
await rm(ROOT_NONE, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await rm(ROOT_A, { recursive: true, force: true });
|
|
27
|
+
await rm(ROOT_B, { recursive: true, force: true });
|
|
28
|
+
await rm(ROOT_NONE, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("hasTodoStore is false until a store has actually persisted a task at that root (openStore alone doesn't create the file)", async () => {
|
|
32
|
+
expect(hasTodoStore(ROOT_A)).toBe(false);
|
|
33
|
+
const s = await openStore(ROOT_A);
|
|
34
|
+
expect(hasTodoStore(ROOT_A)).toBe(false); // still nothing written to disk
|
|
35
|
+
await s.create({ summary: "x", kind: "code" });
|
|
36
|
+
expect(hasTodoStore(ROOT_A)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("listAsksForProject surfaces only blocked-by-human tasks, classified by shape", async () => {
|
|
40
|
+
const s = await openStore(ROOT_A);
|
|
41
|
+
const choice = await s.create({ summary: "pick a channel", kind: "decision" });
|
|
42
|
+
await s.setBlock(choice._id, {
|
|
43
|
+
type: "blocked-by-human",
|
|
44
|
+
who: "taku",
|
|
45
|
+
question: "canary or beta?",
|
|
46
|
+
options: ["canary", "beta"],
|
|
47
|
+
});
|
|
48
|
+
const action = await s.create({ summary: "finish oauth", kind: "human" });
|
|
49
|
+
await s.setBlock(action._id, {
|
|
50
|
+
type: "blocked-by-human",
|
|
51
|
+
who: "taku",
|
|
52
|
+
actionLink: "https://example/oauth",
|
|
53
|
+
});
|
|
54
|
+
const bare = await s.create({ summary: "just fyi", kind: "human" });
|
|
55
|
+
await s.setBlock(bare._id, { type: "blocked-by-human", who: "taku" });
|
|
56
|
+
const notHuman = await s.create({ summary: "not an ask", kind: "code" });
|
|
57
|
+
await s.setBlock(notHuman._id, { type: "blocked-by-external", signal: "ci" });
|
|
58
|
+
const unblocked = await s.create({ summary: "no block at all", kind: "code" });
|
|
59
|
+
|
|
60
|
+
const asks = await listAsksForProject(ROOT_A);
|
|
61
|
+
expect(asks.map((a) => a.taskId).sort()).toEqual([choice._id, action._id, bare._id].sort());
|
|
62
|
+
expect(asks.find((a) => a.taskId === choice._id)).toMatchObject({
|
|
63
|
+
shape: "choice",
|
|
64
|
+
options: ["canary", "beta"],
|
|
65
|
+
question: "canary or beta?",
|
|
66
|
+
});
|
|
67
|
+
expect(asks.find((a) => a.taskId === action._id)).toMatchObject({
|
|
68
|
+
shape: "action",
|
|
69
|
+
actionLink: "https://example/oauth",
|
|
70
|
+
});
|
|
71
|
+
expect(asks.find((a) => a.taskId === bare._id)).toMatchObject({ shape: "acknowledge" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("listAsks aggregates across multiple project roots, skipping ones with no store at all", async () => {
|
|
75
|
+
const sA = await openStore(ROOT_A);
|
|
76
|
+
const tA = await sA.create({ summary: "a", kind: "human" });
|
|
77
|
+
await sA.setBlock(tA._id, { type: "blocked-by-human", who: "taku" });
|
|
78
|
+
const sB = await openStore(ROOT_B);
|
|
79
|
+
const tB = await sB.create({ summary: "b", kind: "human" });
|
|
80
|
+
await sB.setBlock(tB._id, { type: "blocked-by-human", who: "cto" });
|
|
81
|
+
|
|
82
|
+
const asks = await listAsks([ROOT_A, ROOT_B, ROOT_NONE]);
|
|
83
|
+
expect(asks.map((a) => `${a.projectRoot === ROOT_A ? "A" : "B"}:${a.taskId}`).sort()).toEqual(
|
|
84
|
+
[`A:${tA._id}`, `B:${tB._id}`].sort(),
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("listAsks isolates a per-project failure — one unreadable/broken store must not take down the whole cross-project panel (codex-review round-14 Important)", async () => {
|
|
89
|
+
const sA = await openStore(ROOT_A);
|
|
90
|
+
const tA = await sA.create({ summary: "healthy ask", kind: "human" });
|
|
91
|
+
await sA.setBlock(tA._id, { type: "blocked-by-human", who: "taku" });
|
|
92
|
+
|
|
93
|
+
// ROOT_B's todos.jsonl is a DIRECTORY, not a file — a real, easy way to
|
|
94
|
+
// force listAsksForProject(ROOT_B) to genuinely reject (EISDIR) rather
|
|
95
|
+
// than mocking anything.
|
|
96
|
+
await mkdir(path.join(ROOT_B, ".agent-yes", "todos.jsonl"), { recursive: true });
|
|
97
|
+
|
|
98
|
+
const asks = await listAsks([ROOT_A, ROOT_B]);
|
|
99
|
+
expect(asks.map((a) => a.taskId)).toEqual([tA._id]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("answerAsk on a bare acknowledge-shape ask: clears the block, advances the human kind's pending->decided gate as the asked human, then decided->done is ungated", async () => {
|
|
103
|
+
const s = await openStore(ROOT_A);
|
|
104
|
+
const t = await s.create({ summary: "just fyi", kind: "human" });
|
|
105
|
+
await s.setBlock(t._id, { type: "blocked-by-human", who: "taku" });
|
|
106
|
+
const { record: answered } = await answerAsk(ROOT_A, t._id, { acknowledged: true });
|
|
107
|
+
expect(answered.block).toBeNull();
|
|
108
|
+
expect(answered.state).toBe("decided"); // human-replied gate satisfied, advanced
|
|
109
|
+
expect(answered.verifyEvidence[0]).toMatchObject({ gate: "human-replied", validator: "taku" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("answerAsk on a choice-shape ask records the choice text as evidence note, and rejects a choice not among the offered options", async () => {
|
|
113
|
+
const s = await openStore(ROOT_A);
|
|
114
|
+
const t = await s.create({ summary: "pick a channel", kind: "decision" });
|
|
115
|
+
await s.setBlock(t._id, {
|
|
116
|
+
type: "blocked-by-human",
|
|
117
|
+
who: "taku",
|
|
118
|
+
options: ["canary", "beta"],
|
|
119
|
+
});
|
|
120
|
+
await expect(answerAsk(ROOT_A, t._id, { choice: "stable" })).rejects.toThrow(
|
|
121
|
+
/not one of the offered options/,
|
|
122
|
+
);
|
|
123
|
+
const { record: answered } = await answerAsk(ROOT_A, t._id, { choice: "canary" });
|
|
124
|
+
expect(answered.state).toBe("decided");
|
|
125
|
+
expect(answered.verifyEvidence[0]).toMatchObject({ gate: "human-decided", note: "canary" });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("when a block has BOTH options and actionLink set (a direct library caller bypassing the CLI's mutual-exclusivity guard), answerAsk treats it as action-shape — matching listAsksForProject's own precedence, so an ask is never listed as one shape but answerable only as another (codex-review round-9 Important)", async () => {
|
|
129
|
+
const s = await openStore(ROOT_A);
|
|
130
|
+
const t = await s.create({ summary: "x", kind: "human" });
|
|
131
|
+
await s.setBlock(t._id, {
|
|
132
|
+
type: "blocked-by-human",
|
|
133
|
+
who: "taku",
|
|
134
|
+
options: ["canary", "beta"],
|
|
135
|
+
actionLink: "https://example/oauth",
|
|
136
|
+
});
|
|
137
|
+
const asks = await listAsksForProject(ROOT_A);
|
|
138
|
+
expect(asks[0]?.shape).toBe("action");
|
|
139
|
+
// a choice alone (matching the listed shape's own actionLink requirement
|
|
140
|
+
// being ignored) must NOT satisfy it — only acknowledged does, matching
|
|
141
|
+
// the action-shape UI (open link, then confirm)
|
|
142
|
+
await expect(answerAsk(ROOT_A, t._id, { choice: "canary" })).rejects.toThrow(
|
|
143
|
+
/requires \{ acknowledged: true \}/,
|
|
144
|
+
);
|
|
145
|
+
const { record: answered } = await answerAsk(ROOT_A, t._id, { acknowledged: true });
|
|
146
|
+
expect(answered.block).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("answerAsk leaves the task's block INTACT (not silently cleared) if the gate/transition step fails — atomicity means a failure never strands the task in limbo (codex-review round-9 Important)", async () => {
|
|
150
|
+
const s = await openStore(ROOT_A);
|
|
151
|
+
const t = await s.create({ summary: "pick a channel", kind: "decision", owner: "taku" });
|
|
152
|
+
await s.setBlock(t._id, { type: "blocked-by-human", who: "taku", options: ["a", "b"] });
|
|
153
|
+
// "taku" is both the task's owner AND the asked human — approve() will
|
|
154
|
+
// throw "independent verification required" for this specific task,
|
|
155
|
+
// simulating a mid-sequence failure without needing to mock anything.
|
|
156
|
+
await expect(answerAsk(ROOT_A, t._id, { choice: "a" })).rejects.toThrow(
|
|
157
|
+
/independent verification required/,
|
|
158
|
+
);
|
|
159
|
+
// Re-open fresh rather than reusing `s`: `answerAsk()` opens its OWN
|
|
160
|
+
// separate store instance internally, so `s`'s in-memory cache is never
|
|
161
|
+
// reloaded by that call — reading `s` directly here would silently pass
|
|
162
|
+
// even if answerAsk actually cleared the block on disk before throwing
|
|
163
|
+
// (exactly the bug this test exists to catch).
|
|
164
|
+
const stillBlocked = (await openStore(ROOT_A)).get(t._id)!;
|
|
165
|
+
expect(stillBlocked.block).toEqual({
|
|
166
|
+
type: "blocked-by-human",
|
|
167
|
+
who: "taku",
|
|
168
|
+
options: ["a", "b"],
|
|
169
|
+
});
|
|
170
|
+
expect(stillBlocked.state).toBe("deciding"); // unchanged
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("answerAsk NEVER erases a block that was replaced by a DIFFERENT one WHILE the answer was being processed — the final clear only removes the SPECIFIC block this call decided to answer, not whatever block happens to be there by the time it gets around to clearing (codex-review round-15 Important)", async () => {
|
|
174
|
+
const s = await openStore(ROOT_A);
|
|
175
|
+
const t = await s.create({ summary: "x", kind: "human" });
|
|
176
|
+
const original = { type: "blocked-by-human", who: "taku", question: "original ask" } as const;
|
|
177
|
+
await s.setBlock(t._id, original);
|
|
178
|
+
const newerBlock = {
|
|
179
|
+
type: "blocked-by-human",
|
|
180
|
+
who: "taku",
|
|
181
|
+
question: "a brand new ask",
|
|
182
|
+
} as const;
|
|
183
|
+
|
|
184
|
+
// Deterministically inject a concurrent block replacement between
|
|
185
|
+
// answerAsk's initial read (which captures the blockRev it expects to
|
|
186
|
+
// still be current) and its atomic answerHumanBlock() write — patching
|
|
187
|
+
// TodoStore.prototype.answerHumanBlock (which every openStore()
|
|
188
|
+
// instance, including the one answerAsk creates internally, shares) so
|
|
189
|
+
// a SECOND, independent store instance replaces the block right BEFORE
|
|
190
|
+
// the real atomic write runs, simulating a genuinely different process
|
|
191
|
+
// racing this exact window. A real race would be flaky to reproduce
|
|
192
|
+
// reliably; this reproduces it deterministically every run.
|
|
193
|
+
const originalAnswerHumanBlock = TodoStore.prototype.answerHumanBlock;
|
|
194
|
+
const spy = vi
|
|
195
|
+
.spyOn(TodoStore.prototype, "answerHumanBlock")
|
|
196
|
+
.mockImplementation(async function (
|
|
197
|
+
this: TodoStore,
|
|
198
|
+
id: string,
|
|
199
|
+
expectedBlockRev: number,
|
|
200
|
+
gate: Parameters<TodoStore["answerHumanBlock"]>[2],
|
|
201
|
+
) {
|
|
202
|
+
const other = await openStore(ROOT_A);
|
|
203
|
+
await other.setBlock(id, newerBlock);
|
|
204
|
+
return originalAnswerHumanBlock.call(this, id, expectedBlockRev, gate);
|
|
205
|
+
});
|
|
206
|
+
try {
|
|
207
|
+
await expect(answerAsk(ROOT_A, t._id, { acknowledged: true })).rejects.toThrow(
|
|
208
|
+
/this ask has changed since it was loaded/,
|
|
209
|
+
);
|
|
210
|
+
} finally {
|
|
211
|
+
spy.mockRestore();
|
|
212
|
+
}
|
|
213
|
+
// The newer block survives untouched — not silently wiped by the stale
|
|
214
|
+
// "clear the original block" decision.
|
|
215
|
+
const fresh = (await openStore(ROOT_A)).get(t._id)!;
|
|
216
|
+
expect(fresh.block).toEqual(newerBlock);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("a naive retry after answerAsk already fully succeeded is cleanly refused — it never duplicates evidence or re-transitions, because the gate/transition/clear are now ONE atomic write with no partial-completion state for a retry to resume from (codex-review round-18: replaces the old approve()+transition()+clearBlockIfMatches composition's retry-idempotency workaround, which is no longer needed)", async () => {
|
|
220
|
+
const s = await openStore(ROOT_A);
|
|
221
|
+
const t = await s.create({ summary: "pick a channel", kind: "decision" });
|
|
222
|
+
await s.setBlock(t._id, { type: "blocked-by-human", who: "taku", options: ["a", "b"] });
|
|
223
|
+
const { record: first } = await answerAsk(ROOT_A, t._id, { choice: "a" });
|
|
224
|
+
expect(first.state).toBe("decided");
|
|
225
|
+
expect(first.verifyEvidence).toHaveLength(1);
|
|
226
|
+
// A retry (e.g. the client never saw the first response and resubmits)
|
|
227
|
+
// sees the block already cleared — a clear, accurate rejection, not a
|
|
228
|
+
// silent no-op and not a duplicate evidence entry.
|
|
229
|
+
await expect(answerAsk(ROOT_A, t._id, { choice: "a" })).rejects.toThrow(
|
|
230
|
+
/not currently blocked-by-human/,
|
|
231
|
+
);
|
|
232
|
+
const stillDone = (await openStore(ROOT_A)).get(t._id)!;
|
|
233
|
+
expect(stillDone.verifyEvidence).toHaveLength(1); // never duplicated
|
|
234
|
+
expect(stillDone.state).toBe("decided"); // never re-transitioned
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("answerAsk IGNORES an unvalidated `choice` sent alongside `acknowledged: true` on a non-choice-shape ask — an arbitrary attacker-supplied string must never become the recorded/relayed answer just because it rode along with a valid acknowledgement (codex-review round-14 Important)", async () => {
|
|
238
|
+
const s = await openStore(ROOT_A);
|
|
239
|
+
const bare = await s.create({ summary: "just fyi", kind: "human" });
|
|
240
|
+
await s.setBlock(bare._id, { type: "blocked-by-human", who: "taku" });
|
|
241
|
+
const bareResult = await answerAsk(ROOT_A, bare._id, {
|
|
242
|
+
acknowledged: true,
|
|
243
|
+
choice: "attacker-controlled garbage\nrm -rf /",
|
|
244
|
+
});
|
|
245
|
+
expect(bareResult.answerText).toBe("acknowledged");
|
|
246
|
+
expect(bareResult.record.verifyEvidence[0]).toMatchObject({ note: "acknowledged" });
|
|
247
|
+
|
|
248
|
+
const action = await s.create({ summary: "finish oauth", kind: "human" });
|
|
249
|
+
await s.setBlock(action._id, {
|
|
250
|
+
type: "blocked-by-human",
|
|
251
|
+
who: "taku",
|
|
252
|
+
actionLink: "https://example/oauth",
|
|
253
|
+
});
|
|
254
|
+
const actionResult = await answerAsk(ROOT_A, action._id, {
|
|
255
|
+
acknowledged: true,
|
|
256
|
+
choice: "also attacker-controlled",
|
|
257
|
+
});
|
|
258
|
+
expect(actionResult.answerText).toBe("acknowledged");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("answerAsk requires an actual answer for a choice-shape ask (no bare acknowledged)", async () => {
|
|
262
|
+
const s = await openStore(ROOT_A);
|
|
263
|
+
const t = await s.create({ summary: "pick a channel", kind: "decision" });
|
|
264
|
+
await s.setBlock(t._id, { type: "blocked-by-human", who: "taku", options: ["a", "b"] });
|
|
265
|
+
await expect(answerAsk(ROOT_A, t._id, { acknowledged: true })).rejects.toThrow(
|
|
266
|
+
/requires a choice/,
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("answerAsk refuses a task that isn't currently blocked-by-human (already answered, or a different block type)", async () => {
|
|
271
|
+
const s = await openStore(ROOT_A);
|
|
272
|
+
const t = await s.create({ summary: "x", kind: "human" });
|
|
273
|
+
await expect(answerAsk(ROOT_A, t._id, { acknowledged: true })).rejects.toThrow(
|
|
274
|
+
/not currently blocked-by-human/,
|
|
275
|
+
);
|
|
276
|
+
await s.setBlock(t._id, { type: "blocked-by-external", signal: "ci" });
|
|
277
|
+
await expect(answerAsk(ROOT_A, t._id, { acknowledged: true })).rejects.toThrow(
|
|
278
|
+
/not currently blocked-by-human/,
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("answerAsk on a task whose current state has NO gated outgoing edge just clears the block (no transition to apply)", async () => {
|
|
283
|
+
const s = await openStore(ROOT_A);
|
|
284
|
+
const t = await s.create({ summary: "x", kind: "code" }); // doing -> merged is ungated
|
|
285
|
+
await s.setBlock(t._id, {
|
|
286
|
+
type: "blocked-by-human",
|
|
287
|
+
who: "taku",
|
|
288
|
+
question: "still working on this?",
|
|
289
|
+
});
|
|
290
|
+
const { record: answered } = await answerAsk(ROOT_A, t._id, { acknowledged: true });
|
|
291
|
+
expect(answered.block).toBeNull();
|
|
292
|
+
expect(answered.state).toBe("doing"); // unchanged — nothing gated to advance
|
|
293
|
+
expect(answered.verifyEvidence).toEqual([]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("answerAsk NEVER auto-transitions a code/doc/investigation task, even if its current gated edge would (wrongly) look unregistered to a fresh store instance — only human/decision kinds auto-advance", async () => {
|
|
297
|
+
// Register "verify-green" on THIS process's own store instance, exactly
|
|
298
|
+
// as a real project would — answerAsk() opens its OWN fresh instance
|
|
299
|
+
// internally and has no visibility into this registration, which is
|
|
300
|
+
// exactly the ambiguity that makes auto-transition unsafe for any kind
|
|
301
|
+
// other than human/decision (see answerAsk's doc comment).
|
|
302
|
+
const s = await openStore(ROOT_A);
|
|
303
|
+
s.registerGate({ name: "verify-green", check: async () => ({ passed: true }) });
|
|
304
|
+
const t = await s.create({ summary: "x", kind: "code", owner: "worker" });
|
|
305
|
+
await s.transition(t._id, "merged");
|
|
306
|
+
await s.transition(t._id, "shipped");
|
|
307
|
+
await s.transition(t._id, "verifying");
|
|
308
|
+
await s.setBlock(t._id, { type: "blocked-by-human", who: "taku", question: "any concerns?" });
|
|
309
|
+
const { record: answered } = await answerAsk(ROOT_A, t._id, { acknowledged: true });
|
|
310
|
+
expect(answered.block).toBeNull();
|
|
311
|
+
expect(answered.state).toBe("verifying"); // unchanged — code kind never auto-transitions on a human answer
|
|
312
|
+
expect(answered.verifyEvidence).toEqual([]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("listAsksForProject reports blockRev, and answerAsk refuses an expectedBlockRev that no longer matches — even when the CURRENT block has byte-for-byte identical text to what the caller last saw, since a content-only check could not distinguish 'still the same ask' from 'a new, identical-looking one' (codex-review round-17 Important)", async () => {
|
|
316
|
+
const s = await openStore(ROOT_A);
|
|
317
|
+
const t = await s.create({ summary: "pick a channel", kind: "decision" });
|
|
318
|
+
const identicalBlock: TodoBlock = {
|
|
319
|
+
type: "blocked-by-human",
|
|
320
|
+
who: "taku",
|
|
321
|
+
question: "canary or beta?",
|
|
322
|
+
options: ["canary", "beta"],
|
|
323
|
+
};
|
|
324
|
+
await s.setBlock(t._id, identicalBlock);
|
|
325
|
+
const [ask] = await listAsksForProject(ROOT_A);
|
|
326
|
+
const staleRev = ask!.blockRev;
|
|
327
|
+
|
|
328
|
+
// Someone re-blocks with the EXACT same content before the human's
|
|
329
|
+
// (now-stale) view gets answered.
|
|
330
|
+
await s.setBlock(t._id, identicalBlock);
|
|
331
|
+
|
|
332
|
+
await expect(
|
|
333
|
+
answerAsk(ROOT_A, t._id, { choice: "canary", expectedBlockRev: staleRev }),
|
|
334
|
+
).rejects.toThrow(/this ask has changed since it was loaded/);
|
|
335
|
+
// refused, not silently answered against the newer (identical-looking)
|
|
336
|
+
// block instance
|
|
337
|
+
expect(s.get(t._id)?.block).toEqual(identicalBlock);
|
|
338
|
+
expect(s.get(t._id)?.state).toBe("deciding"); // unchanged — refused before any gate/transition
|
|
339
|
+
|
|
340
|
+
// The CURRENT blockRev succeeds normally.
|
|
341
|
+
const currentRev = (await listAsksForProject(ROOT_A))[0]!.blockRev;
|
|
342
|
+
const { record: answered } = await answerAsk(ROOT_A, t._id, {
|
|
343
|
+
choice: "canary",
|
|
344
|
+
expectedBlockRev: currentRev,
|
|
345
|
+
});
|
|
346
|
+
expect(answered.block).toBeNull();
|
|
347
|
+
expect(answered.state).toBe("decided");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("answerAsk without expectedBlockRev (a direct/programmatic caller that doesn't track it) still works exactly as before — the check is opt-in, not required", async () => {
|
|
351
|
+
const s = await openStore(ROOT_A);
|
|
352
|
+
const t = await s.create({ summary: "x", kind: "human" });
|
|
353
|
+
await s.setBlock(t._id, { type: "blocked-by-human", who: "taku" });
|
|
354
|
+
const { record: answered } = await answerAsk(ROOT_A, t._id, { acknowledged: true });
|
|
355
|
+
expect(answered.block).toBeNull();
|
|
356
|
+
expect(answered.state).toBe("decided");
|
|
357
|
+
});
|
|
358
|
+
});
|
package/ts/askApi.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/ask` decision panel (A7): aggregates every `blocked-by-human` task across
|
|
3
|
+
* every project this host's agents have registered onto one human-facing
|
|
4
|
+
* view, and closes the loop when a human answers.
|
|
5
|
+
*
|
|
6
|
+
* Pure store logic only — no HTTP, no live-agent notification. `ts/serve.ts`
|
|
7
|
+
* wires this into `GET /api/asks` / `POST /api/asks/answer` and, as a
|
|
8
|
+
* best-effort follow-up after `answerAsk()` resolves, writes the answer into
|
|
9
|
+
* the owning agent's terminal via the same FIFO mechanism `/api/send`
|
|
10
|
+
* already uses (see that route) — kept out of this module so it stays
|
|
11
|
+
* testable without a live server or live agent processes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { LIFECYCLES } from "./todoLifecycle.ts";
|
|
17
|
+
import { openStore, type TodoRecord } from "./todoStore.ts";
|
|
18
|
+
|
|
19
|
+
export interface AskItem {
|
|
20
|
+
projectRoot: string;
|
|
21
|
+
taskId: string;
|
|
22
|
+
summary: string;
|
|
23
|
+
who: string;
|
|
24
|
+
question?: string;
|
|
25
|
+
/** `action`: the human must personally complete something at `actionLink` (e.g. OAuth/CAPTCHA), then confirm. `choice`: pick one of `options`. `acknowledge`: a bare question with neither — just needs an acknowledgement. */
|
|
26
|
+
shape: "choice" | "action" | "acknowledge";
|
|
27
|
+
options?: string[];
|
|
28
|
+
actionLink?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Identifies THIS block instance (see `TodoRecord.blockRev`'s doc
|
|
31
|
+
* comment), not just its content. A client SHOULD echo this back as
|
|
32
|
+
* `AnswerInput.expectedBlockRev` when answering — `answerAsk()` then
|
|
33
|
+
* refuses (rather than silently answering) if the block has since changed,
|
|
34
|
+
* even to another block with byte-for-byte identical text (codex-review
|
|
35
|
+
* round-17 Important: without this, a UI showing a stale/failed card for
|
|
36
|
+
* this task could submit an old answer against a genuinely NEW ask on the
|
|
37
|
+
* same task, e.g. if the new question happens to offer an identically-
|
|
38
|
+
* named option).
|
|
39
|
+
*/
|
|
40
|
+
blockRev: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether `projectRoot` has ever had an `ay todo` store created — used to skip candidate project roots (e.g. from a live-agent scan) that never called `ay todo` at all. */
|
|
44
|
+
export function hasTodoStore(projectRoot: string): boolean {
|
|
45
|
+
return existsSync(path.join(projectRoot, ".agent-yes", "todos.jsonl"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function listAsksForProject(projectRoot: string): Promise<AskItem[]> {
|
|
49
|
+
const store = await openStore(projectRoot);
|
|
50
|
+
const asks: AskItem[] = [];
|
|
51
|
+
for (const t of store.all()) {
|
|
52
|
+
if (t.block?.type !== "blocked-by-human") continue;
|
|
53
|
+
const block = t.block;
|
|
54
|
+
asks.push({
|
|
55
|
+
projectRoot,
|
|
56
|
+
taskId: t._id,
|
|
57
|
+
summary: t.summary,
|
|
58
|
+
who: block.who,
|
|
59
|
+
question: block.question,
|
|
60
|
+
options: block.options,
|
|
61
|
+
actionLink: block.actionLink,
|
|
62
|
+
shape: block.actionLink ? "action" : block.options?.length ? "choice" : "acknowledge",
|
|
63
|
+
blockRev: t.blockRev ?? 0,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return asks;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Aggregates asks across every project root that actually has a store —
|
|
71
|
+
* candidates are supplied by the caller (`ts/serve.ts` derives them from the
|
|
72
|
+
* live-agent registry's distinct `cwd`s), keeping this module itself
|
|
73
|
+
* agent-registry-agnostic.
|
|
74
|
+
*
|
|
75
|
+
* Uses `Promise.allSettled`, not `Promise.all`: this is a cross-project
|
|
76
|
+
* aggregation by design, and `Promise.all`'s fail-fast behavior meant ONE
|
|
77
|
+
* project with an unreadable/corrupt store made the ENTIRE decision panel
|
|
78
|
+
* return nothing (a 500 at the route level) — hiding every other project's
|
|
79
|
+
* perfectly healthy asks too (codex-review Important). A failed project is
|
|
80
|
+
* logged and skipped; the healthy ones are still returned.
|
|
81
|
+
*/
|
|
82
|
+
export async function listAsks(projectRoots: string[]): Promise<AskItem[]> {
|
|
83
|
+
const withStore = [...new Set(projectRoots)].filter(hasTodoStore);
|
|
84
|
+
const results = await Promise.allSettled(withStore.map((root) => listAsksForProject(root)));
|
|
85
|
+
const asks: AskItem[] = [];
|
|
86
|
+
for (const [i, result] of results.entries()) {
|
|
87
|
+
if (result.status === "fulfilled") {
|
|
88
|
+
asks.push(...result.value);
|
|
89
|
+
} else {
|
|
90
|
+
// No silent caps without a trace: at least log which project failed
|
|
91
|
+
// and why, so a persistently-broken store is discoverable rather than
|
|
92
|
+
// just quietly absent from the panel forever.
|
|
93
|
+
console.error(`[askApi] listAsksForProject(${withStore[i]}) failed:`, result.reason);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return asks;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface AnswerInput {
|
|
100
|
+
choice?: string;
|
|
101
|
+
acknowledged?: boolean;
|
|
102
|
+
/** See `AskItem.blockRev`'s doc comment. Optional so direct/programmatic callers that don't track it can still call this — but a UI-driven caller (`ts/serve.ts`'s route, `ask.html`) SHOULD always supply it. */
|
|
103
|
+
expectedBlockRev?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Applies a human's answer to a `blocked-by-human` task in ONE call: clears
|
|
108
|
+
* the block, and — for the `human`/`decision` kinds ONLY — satisfies that
|
|
109
|
+
* kind's gated outgoing edge as the asked human (`block.who`, always a
|
|
110
|
+
* different identity from the task's `owner` by construction, satisfying
|
|
111
|
+
* independent verification) and advances the transition. This atomicity is
|
|
112
|
+
* the closed loop's core property for those two kinds, and it is about the
|
|
113
|
+
* TASK'S STORED STATE specifically: there is no intermediate state where the
|
|
114
|
+
* block is cleared but the gate/transition hasn't happened, and no separate
|
|
115
|
+
* step a human or orchestrator has to remember to run afterward. (Real-time
|
|
116
|
+
* delivery of the answer to a currently-live agent is a SEPARATE, best-effort
|
|
117
|
+
* concern handled by the caller — see `ts/serve.ts`'s `/api/asks/answer`
|
|
118
|
+
* route — and deliberately not part of this guarantee: if that delivery
|
|
119
|
+
* fails, the task's state is still correctly updated and durable here, and
|
|
120
|
+
* the agent finds out on its own next normal check, e.g. `ay todo get` or a
|
|
121
|
+
* reconcile pass, rather than through a guaranteed push.)
|
|
122
|
+
*
|
|
123
|
+
* Restricted to `human`/`decision` deliberately: this function opens its own
|
|
124
|
+
* fresh `TodoStore` (via `openStore`), which has NO registered gates of its
|
|
125
|
+
* own — `registerGate()` is an in-memory, per-process registration a
|
|
126
|
+
* consuming project makes on ITS OWN store instance (e.g. Part B registering
|
|
127
|
+
* `sym003-canary-green`), invisible to a store opened here. For `code`/
|
|
128
|
+
* `doc`/`investigation` kinds, a `blocked-by-human` block can sit on a task
|
|
129
|
+
* whose CURRENT state's gated edge is actually a registered, automated gate
|
|
130
|
+
* in the real deployment (verifying->done via a CI/QA check) — blindly
|
|
131
|
+
* checking `isRegisteredGate()` on a throwaway instance would ALWAYS see it
|
|
132
|
+
* as unregistered and incorrectly let a human's mere acknowledgement drive
|
|
133
|
+
* straight past an automated verification step. `human`/`decision` don't
|
|
134
|
+
* have this ambiguity: their gated edges (`human-replied`, `human-decided`)
|
|
135
|
+
* exist SPECIFICALLY to be satisfied by a human answering — there is no
|
|
136
|
+
* automated-gate competition to accidentally bypass. For every other kind,
|
|
137
|
+
* this function only clears the block; whatever actually owns that kind's
|
|
138
|
+
* transition (an agent, `ay todo verify`, etc.) still drives it.
|
|
139
|
+
*/
|
|
140
|
+
export interface AnswerResult {
|
|
141
|
+
record: TodoRecord;
|
|
142
|
+
/**
|
|
143
|
+
* The validated, shape-derived answer text ("acknowledged" for action/bare
|
|
144
|
+
* asks, the matched option for choice-shape ones) — returned alongside the
|
|
145
|
+
* record (whose `block` is already `null` by the time the caller sees it)
|
|
146
|
+
* specifically so a caller wanting to relay this to a live agent (see
|
|
147
|
+
* `ts/serve.ts`'s `/api/asks/answer`) never has to re-derive it from the
|
|
148
|
+
* raw request body itself. Re-deriving it separately from `answer.choice`
|
|
149
|
+
* would reopen exactly the validation gap this field exists to close: an
|
|
150
|
+
* arbitrary, never-checked `choice` string accepted alongside
|
|
151
|
+
* `acknowledged: true` on a non-choice-shape ask (codex-review Important).
|
|
152
|
+
*/
|
|
153
|
+
answerText: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function answerAsk(
|
|
157
|
+
projectRoot: string,
|
|
158
|
+
taskId: string,
|
|
159
|
+
answer: AnswerInput,
|
|
160
|
+
): Promise<AnswerResult> {
|
|
161
|
+
const store = await openStore(projectRoot);
|
|
162
|
+
const rec = store.get(taskId);
|
|
163
|
+
if (!rec) throw new Error(`no such task: ${taskId}`);
|
|
164
|
+
if (rec.block?.type !== "blocked-by-human") {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`task ${taskId} is not currently blocked-by-human (already answered, or blocked on something else)`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
// `expectedBlockRev`, if the caller supplied one, is enforced by the
|
|
170
|
+
// single atomic `answerHumanBlock` write below (compared against the
|
|
171
|
+
// FRESH record at write time, not this snapshot) — see its own doc
|
|
172
|
+
// comment. Not re-checked here against `rec.blockRev` too: that would be
|
|
173
|
+
// the exact same comparison against a snapshot that's already stale by
|
|
174
|
+
// the time this function does anything else, duplicating logic without
|
|
175
|
+
// closing any additional gap (codex-review round-17/18).
|
|
176
|
+
const expectedBlockRev = answer.expectedBlockRev ?? rec.blockRev ?? 0;
|
|
177
|
+
const block = rec.block;
|
|
178
|
+
// actionLink checked FIRST, matching listAsksForProject's own shape
|
|
179
|
+
// precedence exactly — if a block somehow has both `options` and
|
|
180
|
+
// `actionLink` set (the CLI's `ay todo block` rejects that combination,
|
|
181
|
+
// but a direct library caller could still construct one), both functions
|
|
182
|
+
// must agree on which shape it is, or an ask could be listed as
|
|
183
|
+
// action-shape yet still demand a choice here, making it unanswerable via
|
|
184
|
+
// the /ask UI (codex-review Important).
|
|
185
|
+
//
|
|
186
|
+
// `answerText` is derived from the SHAPE'S OWN branch, never from a bare
|
|
187
|
+
// `answer.choice ?? "acknowledged"` fallback — the earlier version only
|
|
188
|
+
// checked `answer.acknowledged` for action/acknowledge-shape asks, so a
|
|
189
|
+
// caller could ALSO send an arbitrary, unvalidated `choice` string
|
|
190
|
+
// alongside `acknowledged: true` and have it accepted as the recorded
|
|
191
|
+
// answer (durable evidence, AND written verbatim to a live agent's
|
|
192
|
+
// terminal via IPC) even though it was never checked against anything —
|
|
193
|
+
// a real injection vector on a non-choice-shape ask (codex-review
|
|
194
|
+
// Important).
|
|
195
|
+
let answerText: string;
|
|
196
|
+
if (block.actionLink) {
|
|
197
|
+
if (!answer.acknowledged) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`task ${taskId}: this ask requires { acknowledged: true } after completing ${block.actionLink}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
answerText = "acknowledged";
|
|
203
|
+
} else if (block.options?.length) {
|
|
204
|
+
if (!answer.choice) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`task ${taskId}: this ask requires a choice (one of: ${block.options.join(", ")})`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (!block.options.includes(answer.choice)) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`task ${taskId}: "${answer.choice}" is not one of the offered options (${block.options.join(", ")})`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
answerText = answer.choice;
|
|
215
|
+
} else {
|
|
216
|
+
// A bare acknowledge-shape ask requires `acknowledged: true` SPECIFICALLY
|
|
217
|
+
// — a `choice` alone used to also satisfy it (nothing to validate a
|
|
218
|
+
// choice against here, so accepting one was a looser, less clear
|
|
219
|
+
// contract than necessary — codex-review nitpick). `answerText` was
|
|
220
|
+
// already always forced to "acknowledged" regardless, so this doesn't
|
|
221
|
+
// change what gets recorded, only what the caller is required to send.
|
|
222
|
+
if (!answer.acknowledged) {
|
|
223
|
+
throw new Error(`task ${taskId}: this ask requires { acknowledged: true }`);
|
|
224
|
+
}
|
|
225
|
+
answerText = "acknowledged";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Gate/transition (human/decision kinds only) and clearing the block are
|
|
229
|
+
// applied via ONE atomic store write (`answerHumanBlock`), not as
|
|
230
|
+
// separately-persisted approve()+transition()+clearBlockIfMatches() calls
|
|
231
|
+
// — an earlier version composed those three, which left a real gap: if
|
|
232
|
+
// approve()/transition() durably succeeded but a THEN-concurrent block
|
|
233
|
+
// replacement made the final clear fail, the task was left already
|
|
234
|
+
// transitioned/evidenced for the OLD block while `block` still held a
|
|
235
|
+
// DIFFERENT, newer ask that was never gated at all (codex-review round-18
|
|
236
|
+
// Important). With one atomic write, either everything below applies
|
|
237
|
+
// together or nothing does — there is no partial-completion state, and so
|
|
238
|
+
// no separate retry-idempotency/conflict-detection logic is needed either:
|
|
239
|
+
// a genuine retry after a full success simply sees `rec.block` already
|
|
240
|
+
// `null` and is rejected by the "not currently blocked-by-human" check at
|
|
241
|
+
// the top of this function, which is itself an accurate, clear outcome.
|
|
242
|
+
const gate =
|
|
243
|
+
rec.kind === "human" || rec.kind === "decision"
|
|
244
|
+
? (() => {
|
|
245
|
+
const primary = LIFECYCLES[rec.kind].transitions.find(
|
|
246
|
+
(tr) => tr.from === rec.state && tr.gate,
|
|
247
|
+
);
|
|
248
|
+
return primary?.gate
|
|
249
|
+
? { name: primary.gate, toState: primary.to, validator: block.who, note: answerText }
|
|
250
|
+
: null;
|
|
251
|
+
})()
|
|
252
|
+
: null;
|
|
253
|
+
const record = await store.answerHumanBlock(taskId, expectedBlockRev, gate);
|
|
254
|
+
return { record, answerText };
|
|
255
|
+
}
|
package/ts/index.ts
CHANGED
|
@@ -76,6 +76,8 @@ export type { TodoBlock, MonitorHint } from "./todoBlock.ts";
|
|
|
76
76
|
export { unblockedTasks, openBlockers, renderTree, renderDigest } from "./todoDigest.ts";
|
|
77
77
|
export { reconcileTodos } from "./todoAutomation.ts";
|
|
78
78
|
export type { TodoAction, LiveAgent } from "./todoAutomation.ts";
|
|
79
|
+
export { listAsks, listAsksForProject, answerAsk, hasTodoStore } from "./askApi.ts";
|
|
80
|
+
export type { AskItem, AnswerInput, AnswerResult } from "./askApi.ts";
|
|
79
81
|
|
|
80
82
|
export type AgentCliConfig = {
|
|
81
83
|
// cli
|