@danypops/papyrus 0.20.0 → 0.21.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 +2 -0
- package/extension/src/domain-tools.ts +89 -16
- package/package.json +1 -1
- package/src/adapters/sqlite-gate-runner.ts +2 -2
- package/src/domain/gate.ts +12 -0
- package/src/ops.ts +61 -12
- package/src/ports/gate-runner.ts +1 -1
- package/src/task-service.ts +6 -3
package/README.md
CHANGED
|
@@ -179,6 +179,8 @@ papyrus discuss show <discussion-id> --json
|
|
|
179
179
|
|
|
180
180
|
## Tasks
|
|
181
181
|
|
|
182
|
+
The `tasks` agent tool addresses a task by `name` (its exact title) wherever `id` would otherwise be required -- `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` are the name-based equivalents of `dependency_id`/`parent_id`/`child_id`/`root_task_id`/`depends_on`. Resolution is an exact, case-insensitive, trimmed title match scoped like a plain list call; an unmatched or ambiguous name fails with a clear error (ambiguous names list the real ids, since that's the one point disambiguation genuinely needs them). Task results returned to the agent likewise lead with name and status, never id, unless two tasks in the same result share a title -- id is a backend implementation detail, not a conversational handle. `id` itself still works exactly as before for every action.
|
|
183
|
+
|
|
182
184
|
Run `/tasks` for the interactive task panel:
|
|
183
185
|
|
|
184
186
|
- `/` filters; arrow keys navigate; Enter opens task actions; `s` switches among the persisted current-project, focused-root graph, and explicit all-projects views
|
|
@@ -33,6 +33,47 @@ function artifactLine(artifact: Artifact): string {
|
|
|
33
33
|
return `${artifact.id} [${artifact.status}] ${artifact.title}`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Tasks-only: the model's primary interfacing point is the task's NAME, not its id -- id is a
|
|
38
|
+
* backend detail (a stable key other operations need, and titles aren't guaranteed unique), so
|
|
39
|
+
* it stays out of what the model reads by default. It only resurfaces when genuinely needed to
|
|
40
|
+
* tell two same-titled tasks apart (taskLines below), or in a matchTaskByName disambiguation
|
|
41
|
+
* error, never as a matter of course. This is scoped to the tasks tool specifically -- Docs/
|
|
42
|
+
* Rules/Skills/Discuss keep the shared artifactLine above unless a similar request covers them.
|
|
43
|
+
*/
|
|
44
|
+
export function taskLine(task: Artifact): string {
|
|
45
|
+
return `[${task.status}] ${task.title}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Appends " (id)" only for tasks whose title collides with another in this same result set. */
|
|
49
|
+
export function taskLines(tasks: Artifact[]): string[] {
|
|
50
|
+
const titleCounts = new Map<string, number>();
|
|
51
|
+
for (const task of tasks) titleCounts.set(task.title, (titleCounts.get(task.title) ?? 0) + 1);
|
|
52
|
+
return tasks.map((task) => (titleCounts.get(task.title)! > 1 ? `${taskLine(task)} (${task.id})` : taskLine(task)));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Exact, case-insensitive, trimmed title match against an already-fetched candidate set. Throws
|
|
57
|
+
* a clear "not found" or "ambiguous -- use id" error rather than guessing at a fuzzy match -- id
|
|
58
|
+
* remains the one truly unambiguous key, so ambiguity is exactly where it's allowed to resurface.
|
|
59
|
+
* Pure and synchronous so it's directly testable without a service round-trip.
|
|
60
|
+
*/
|
|
61
|
+
export function matchTaskByName(candidates: Artifact[], name: string): string {
|
|
62
|
+
const needle = name.trim().toLowerCase();
|
|
63
|
+
const matches = candidates.filter((task) => task.title.trim().toLowerCase() === needle);
|
|
64
|
+
if (matches.length === 0) throw new Error(`no task named "${name}" found in this scope`);
|
|
65
|
+
if (matches.length > 1) {
|
|
66
|
+
throw new Error(`${matches.length} tasks are named "${name}": ${matches.map((task) => `${task.title} (${task.id})`).join(", ")} -- use id to disambiguate`);
|
|
67
|
+
}
|
|
68
|
+
return matches[0]!.id;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Resolves a task name to its id, scoped the same way a plain `tasks list` call would be (same project_root/session_id/scope). */
|
|
72
|
+
async function resolveTaskIdByName(baseRequest: Record<string, unknown>, name: string): Promise<string> {
|
|
73
|
+
const candidates = await callService<Record<string, unknown>, Artifact[]>("tasks.list", { ...baseRequest, text: name });
|
|
74
|
+
return matchTaskByName(candidates, name);
|
|
75
|
+
}
|
|
76
|
+
|
|
36
77
|
/**
|
|
37
78
|
* Shared "remove"/"restore" dispatch for every domain tool (tasks/docs/rules/skills) --
|
|
38
79
|
* artifact.remove/restore are kind-agnostic composition-root operations (see service.ts),
|
|
@@ -67,10 +108,11 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
67
108
|
pi.registerTool({
|
|
68
109
|
name: "tasks",
|
|
69
110
|
label: "Tasks",
|
|
70
|
-
description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, undepend, contain, uncontain, remove, restore. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. update can recover a Task accidentally created terminal by setting status=todo with a reason, but cannot rewrite legitimate lifecycle history. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. remove moves a Task to a time-gated trash (restorable via restore until the purge deadline; refuses if it is the live Task Focus). Prefer this over low-level papyrus_* tools for task work.",
|
|
111
|
+
description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, undepend, contain, uncontain, remove, restore. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. update can recover a Task accidentally created terminal by setting status=todo with a reason, but cannot rewrite legitimate lifecycle history. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. remove moves a Task to a time-gated trash (restorable via restore until the purge deadline; refuses if it is the live Task Focus). PREFER addressing a task by `name` (its exact title) over `id` for every action -- id is a backend implementation detail, resolved from name automatically, and only needs to appear explicitly when a name is genuinely ambiguous (two tasks share a title; the error will say so and list the real ids to disambiguate with). Task results likewise show name and status, not id, unless two shown tasks share a title. `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` are the name-based equivalents of `dependency_id`/`parent_id`/`child_id`/`root_task_id`/`depends_on`. Prefer this over low-level papyrus_* tools for task work.",
|
|
71
112
|
parameters: Type.Object({
|
|
72
113
|
action: Type.String(),
|
|
73
114
|
id: Type.Optional(Type.String()),
|
|
115
|
+
name: Type.Optional(Type.String()),
|
|
74
116
|
title: Type.Optional(Type.String()),
|
|
75
117
|
body: Type.Optional(Type.String()),
|
|
76
118
|
status: Type.Optional(Type.String()),
|
|
@@ -86,17 +128,23 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
86
128
|
checklist: Type.Optional(Type.Record(Type.String(), checklistCriterionSchema)),
|
|
87
129
|
template_id: Type.Optional(Type.String()),
|
|
88
130
|
parent_id: Type.Optional(Type.String()),
|
|
131
|
+
parent_name: Type.Optional(Type.String()),
|
|
89
132
|
child_id: Type.Optional(Type.String()),
|
|
133
|
+
child_name: Type.Optional(Type.String()),
|
|
90
134
|
dependency_id: Type.Optional(Type.String()),
|
|
135
|
+
dependency_name: Type.Optional(Type.String()),
|
|
91
136
|
depends_on: Type.Optional(Type.Array(Type.String())),
|
|
137
|
+
depends_on_names: Type.Optional(Type.Array(Type.String())),
|
|
92
138
|
project_root: Type.Optional(Type.String()),
|
|
93
139
|
scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("graph"), Type.Literal("all")])),
|
|
94
140
|
root_task_id: Type.Optional(Type.String()),
|
|
141
|
+
root_task_name: Type.Optional(Type.String()),
|
|
95
142
|
}),
|
|
96
143
|
renderCall(args, theme) { return renderPapyrusToolCall("Tasks", args, theme); },
|
|
97
144
|
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
98
|
-
async execute(_id,
|
|
145
|
+
async execute(_id, rawParams, _signal, _onUpdate, ctx) {
|
|
99
146
|
try {
|
|
147
|
+
const params: Record<string, unknown> = { ...rawParams };
|
|
100
148
|
const action = params.action;
|
|
101
149
|
// Defaults to this Pi session's own id so Focus reads/writes are isolated per agent
|
|
102
150
|
// without depending on the model to know or supply its own session identity.
|
|
@@ -105,18 +153,37 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
105
153
|
// session never gets this session's secret smuggled in on its behalf -- the cache only
|
|
106
154
|
// ever holds this extension's own registered session anyway (see session-identity.ts).
|
|
107
155
|
const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
|
|
108
|
-
const
|
|
156
|
+
const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: resolvedSessionId, ...sessionSecretField(resolvedSessionId as string) };
|
|
157
|
+
// Resolves every *_name field to its *_id counterpart before dispatch, so every action
|
|
158
|
+
// below can go on reading id/dependency_id/parent_id/child_id/root_task_id exactly as
|
|
159
|
+
// before -- id-based calls are unaffected; name-based ones are transparently rewritten.
|
|
160
|
+
const resolveField = async (nameKey: string, idKey: string) => {
|
|
161
|
+
const nameValue = params[nameKey];
|
|
162
|
+
if (typeof nameValue === "string" && nameValue.length > 0 && !params[idKey]) {
|
|
163
|
+
params[idKey] = await resolveTaskIdByName(baseRequest, nameValue);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
await resolveField("name", "id");
|
|
167
|
+
await resolveField("dependency_name", "dependency_id");
|
|
168
|
+
await resolveField("parent_name", "parent_id");
|
|
169
|
+
await resolveField("child_name", "child_id");
|
|
170
|
+
await resolveField("root_task_name", "root_task_id");
|
|
171
|
+
const dependsOnNames = params["depends_on_names"];
|
|
172
|
+
if (Array.isArray(dependsOnNames) && dependsOnNames.length > 0 && !params["depends_on"]) {
|
|
173
|
+
params["depends_on"] = await Promise.all(dependsOnNames.map((entry) => resolveTaskIdByName(baseRequest, String(entry))));
|
|
174
|
+
}
|
|
175
|
+
const request = { ...params, ...baseRequest };
|
|
109
176
|
if (action === "create") {
|
|
110
177
|
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
|
|
111
|
-
return text(`Created task ${
|
|
178
|
+
return text(`Created task ${taskLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
|
|
112
179
|
}
|
|
113
180
|
if (action === "list") {
|
|
114
181
|
const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", request);
|
|
115
|
-
return text(rows.length ? rows
|
|
182
|
+
return text(rows.length ? taskLines(rows).join("\n") : "No tasks found.", createArtifactListDetails("tasks.list", rows));
|
|
116
183
|
}
|
|
117
184
|
if (action === "show") {
|
|
118
185
|
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
|
|
119
|
-
return text(`${
|
|
186
|
+
return text(`${taskLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("tasks.show", artifact));
|
|
120
187
|
}
|
|
121
188
|
if (action === "history") {
|
|
122
189
|
const page = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", request);
|
|
@@ -131,20 +198,20 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
131
198
|
if (action === "active") {
|
|
132
199
|
const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", request);
|
|
133
200
|
return artifact
|
|
134
|
-
? text(`Active: ${
|
|
201
|
+
? text(`Active: ${taskLine(artifact)}`, createArtifactDetails("tasks.active", artifact))
|
|
135
202
|
: text("No active task.", createPreviewDetails("tasks.active", "Active task", "No active task."));
|
|
136
203
|
}
|
|
137
204
|
if (action === "focused") {
|
|
138
205
|
const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string } | null>("tasks.focused", request);
|
|
139
206
|
return focus
|
|
140
|
-
? text(`Focused (${focus.status}): ${
|
|
207
|
+
? text(`Focused (${focus.status}): ${taskLine(focus.artifact)}`, createArtifactDetails("tasks.focused", focus.artifact))
|
|
141
208
|
: text("No focused task.", createPreviewDetails("tasks.focused", "Focused task", "No focused task."));
|
|
142
209
|
}
|
|
143
210
|
if (action === "pause" || action === "unpause") {
|
|
144
211
|
const operation = action === "pause" ? "tasks.pause" : "tasks.unpause";
|
|
145
212
|
const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, request);
|
|
146
213
|
emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId: request.session_id as string, status: action === "pause" ? "paused" : "unpaused" });
|
|
147
|
-
return text(`Focused (${focus.status}): ${
|
|
214
|
+
return text(`Focused (${focus.status}): ${taskLine(focus.artifact)}`, createArtifactDetails(operation, focus.artifact));
|
|
148
215
|
}
|
|
149
216
|
if (action === "clear_focus") {
|
|
150
217
|
const result = await callService<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", request);
|
|
@@ -168,11 +235,16 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
168
235
|
if (action === "plan") {
|
|
169
236
|
const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", request);
|
|
170
237
|
const byId = new Map(plan.nodes.map((node) => [node.id, node]));
|
|
238
|
+
const titleCounts = new Map<string, number>();
|
|
239
|
+
for (const node of plan.nodes) titleCounts.set(node.title, (titleCounts.get(node.title) ?? 0) + 1);
|
|
171
240
|
const lines = plan.layers.flatMap((layer, index) => [
|
|
172
241
|
`Layer ${index + 1}`,
|
|
173
242
|
...layer.map((id) => {
|
|
174
243
|
const node = byId.get(id);
|
|
175
|
-
|
|
244
|
+
if (!node) return ` [unknown] ${id}`;
|
|
245
|
+
return (titleCounts.get(node.title) ?? 0) > 1
|
|
246
|
+
? ` [${node.state}] ${node.title} (${node.id})`
|
|
247
|
+
: ` [${node.state}] ${node.title}`;
|
|
176
248
|
}),
|
|
177
249
|
]);
|
|
178
250
|
if (plan.cycleIds.length > 0) lines.push(`Invalid cycle: ${plan.cycleIds.join(", ")}`);
|
|
@@ -181,24 +253,25 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
181
253
|
}
|
|
182
254
|
if (action === "set_checklist") {
|
|
183
255
|
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_checklist", params);
|
|
184
|
-
return text(`Updated checklist: ${
|
|
256
|
+
return text(`Updated checklist: ${taskLine(artifact)}`, createArtifactDetails("tasks.set_checklist", artifact));
|
|
185
257
|
}
|
|
186
258
|
if (action === "complete") {
|
|
187
259
|
const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", request);
|
|
188
260
|
const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
|
|
189
261
|
const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
|
|
190
|
-
const focused = result.focused ? `\nActive: ${
|
|
262
|
+
const focused = result.focused ? `\nActive: ${taskLine(result.focused)}` : "";
|
|
263
|
+
const blockedLines = taskLines(result.blocked.map((entry) => entry.artifact));
|
|
191
264
|
const blocked = result.blocked.length > 0
|
|
192
|
-
? `\nBlocked: ${result.blocked.map((entry) => `${
|
|
265
|
+
? `\nBlocked: ${result.blocked.map((entry, index) => `${blockedLines[index]} waits for ${entry.dependencyIds.join(", ")}`).join("; ")}`
|
|
193
266
|
: "";
|
|
194
|
-
const output = `${result.completed ? "Completed" : "Rejected"}: ${
|
|
267
|
+
const output = `${result.completed ? "Completed" : "Rejected"}: ${taskLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`;
|
|
195
268
|
return text(output, createPreviewDetails("tasks.complete", "Task completion", output));
|
|
196
269
|
}
|
|
197
270
|
if (action === "run_gates") {
|
|
198
271
|
const gates = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", request);
|
|
199
272
|
return text(
|
|
200
273
|
gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.",
|
|
201
|
-
createGateRunDetails("tasks.run_gates", params.id ?? "", gates.map((gate) => ({
|
|
274
|
+
createGateRunDetails("tasks.run_gates", (params.id as string | undefined) ?? "", gates.map((gate) => ({
|
|
202
275
|
passed: gate.passed, type: gate.gate.type, target: gate.gate.target, output: gate.output,
|
|
203
276
|
}))),
|
|
204
277
|
);
|
|
@@ -224,7 +297,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
224
297
|
if (!operation) throw new Error(`unknown tasks action: ${action}`);
|
|
225
298
|
const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
|
|
226
299
|
if (operation === "tasks.focus") emitTaskFocusEvent({ taskId: artifact.id, sessionId: request.session_id as string, status: "focused" });
|
|
227
|
-
return text(
|
|
300
|
+
return text(taskLine(artifact), createArtifactDetails(operation, artifact));
|
|
228
301
|
} catch (error) {
|
|
229
302
|
throw new Error(`tasks failed: ${error instanceof Error ? error.message : error}`);
|
|
230
303
|
}
|
package/package.json
CHANGED
|
@@ -6,8 +6,8 @@ import { runGates, runGatesAsync } from "../ops.ts";
|
|
|
6
6
|
export class SQLiteGateRunner implements GateRunner {
|
|
7
7
|
constructor(private readonly db: Db) {}
|
|
8
8
|
|
|
9
|
-
run(artifactId: string): GateResult[] {
|
|
10
|
-
return runGates(this.db, artifactId);
|
|
9
|
+
run(artifactId: string, options?: GateRunOptions): GateResult[] {
|
|
10
|
+
return runGates(this.db, artifactId, options);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
runAsync(artifactId: string, options?: GateRunOptions): Promise<GateResult[]> {
|
package/src/domain/gate.ts
CHANGED
|
@@ -7,6 +7,18 @@ export interface Gate {
|
|
|
7
7
|
export interface GateRunOptions {
|
|
8
8
|
/** Absolute Unix epoch deadline for the full gate sequence. */
|
|
9
9
|
deadlineMs?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Working directory for "command"/"test" gates. Without this, a command gate inherits the
|
|
12
|
+
* Papyrus daemon's own process cwd (its systemd unit's launch directory, e.g. the user's home
|
|
13
|
+
* directory) rather than the task's project -- a real incident: a `bun test` command gate ran
|
|
14
|
+
* against the daemon's home directory instead of the task's project, recursively discovering
|
|
15
|
+
* and attempting to run every test file under every project on the machine, which exhausted
|
|
16
|
+
* memory and crashed the `bun` process outright (SIGILL/SIGABRT), well past the configured
|
|
17
|
+
* gate timeout because the timeout only ever terminated the immediate shell, not the process
|
|
18
|
+
* group it spawned (see executeGateCommand). Task-scoped completion must always pass the
|
|
19
|
+
* task's project_root here.
|
|
20
|
+
*/
|
|
21
|
+
cwd?: string;
|
|
10
22
|
}
|
|
11
23
|
|
|
12
24
|
export interface GateResult {
|
package/src/ops.ts
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* Enforces the schema protocol (kinds, statuses, relations) via FK + app validation.
|
|
4
4
|
*/
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
|
-
import { exec } from "node:child_process";
|
|
7
6
|
import type { Db } from "./db.ts";
|
|
8
7
|
import { inTransaction } from "./db.ts";
|
|
9
8
|
import { ARTIFACT_TRASH_RETENTION_MS, DEFAULT_STATUS_BY_KIND } from "./constants.ts";
|
|
@@ -508,10 +507,11 @@ function readBoundedGateFile(path: string): string {
|
|
|
508
507
|
return readFileSync(path, "utf-8") as string;
|
|
509
508
|
}
|
|
510
509
|
|
|
511
|
-
export function runGates(db: Db, artifactId: string): GateResult[] {
|
|
510
|
+
export function runGates(db: Db, artifactId: string, options: GateRunOptions = {}): GateResult[] {
|
|
512
511
|
const art = getArtifact(db, artifactId);
|
|
513
512
|
if (!art) throw new Error("artifact not found");
|
|
514
513
|
const gates = (art.extra["gates"] as Gate[]) ?? [];
|
|
514
|
+
const cwd = options.cwd;
|
|
515
515
|
return gates.map((gate) => {
|
|
516
516
|
switch (gate.type) {
|
|
517
517
|
case "file-exists": {
|
|
@@ -531,7 +531,7 @@ export function runGates(db: Db, artifactId: string): GateResult[] {
|
|
|
531
531
|
case "command": {
|
|
532
532
|
const { execSync } = require_("node:child_process");
|
|
533
533
|
try {
|
|
534
|
-
const output = execSync(gate.target, { encoding: "utf-8", timeout: GATE_COMMAND_TIMEOUT_MS, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
534
|
+
const output = execSync(gate.target, { encoding: "utf-8", timeout: GATE_COMMAND_TIMEOUT_MS, stdio: ["pipe", "pipe", "pipe"], ...(cwd ? { cwd } : {}) }).trim();
|
|
535
535
|
const passed = gate.expect ? output.includes(gate.expect) : true;
|
|
536
536
|
return { gate, passed, output: output.slice(0, GATE_OUTPUT_LIMIT) };
|
|
537
537
|
} catch (e) {
|
|
@@ -541,7 +541,7 @@ export function runGates(db: Db, artifactId: string): GateResult[] {
|
|
|
541
541
|
case "test": {
|
|
542
542
|
const { execSync } = require_("node:child_process");
|
|
543
543
|
try {
|
|
544
|
-
execSync(`npx vitest run ${gate.target} --reporter=dot`, { encoding: "utf-8", timeout: GATE_TEST_TIMEOUT_MS, stdio: ["pipe", "pipe", "pipe"] });
|
|
544
|
+
execSync(`npx vitest run ${gate.target} --reporter=dot`, { encoding: "utf-8", timeout: GATE_TEST_TIMEOUT_MS, stdio: ["pipe", "pipe", "pipe"], ...(cwd ? { cwd } : {}) });
|
|
545
545
|
return { gate, passed: true, output: "tests passed" };
|
|
546
546
|
} catch (e) {
|
|
547
547
|
return { gate, passed: false, output: e instanceof Error ? e.message.slice(0, GATE_OUTPUT_LIMIT) : "tests failed" };
|
|
@@ -553,15 +553,64 @@ export function runGates(db: Db, artifactId: string): GateResult[] {
|
|
|
553
553
|
});
|
|
554
554
|
}
|
|
555
555
|
|
|
556
|
-
|
|
556
|
+
/**
|
|
557
|
+
* Runs one gate command with two invariants a prior implementation lacked (a real incident; see
|
|
558
|
+
* GateRunOptions.cwd's doc comment):
|
|
559
|
+
* 1. `cwd` is always explicit, never inherited from the daemon's own process cwd.
|
|
560
|
+
* 2. The whole process group is killed on timeout, not just the immediate shell. `exec()`'s own
|
|
561
|
+
* `timeout` option only signals the process it directly spawned (the shell running
|
|
562
|
+
* `command`); a shell's own child (e.g. `bun` under `sh -c "bun test"`) is not in general
|
|
563
|
+
* killed by that signal and can be reparented and keep running -- and consuming memory --
|
|
564
|
+
* indefinitely after Papyrus considers the gate "timed out". Spawning detached (its own
|
|
565
|
+
* process group) and killing the negated pid on our own timer reaches the whole tree.
|
|
566
|
+
*/
|
|
567
|
+
function executeGateCommand(command: string, timeout: number, cwd?: string): Promise<{ passed: boolean; output: string }> {
|
|
568
|
+
// `spawn(..., { shell: true, detached: true })` instead of the `exec()` convenience wrapper:
|
|
569
|
+
// `detached` (needed to make the shell the leader of its own process group, so the negated pid
|
|
570
|
+
// below reaches every descendant, not just the shell) is not part of Node's `exec()`/
|
|
571
|
+
// `ExecOptions` type at all -- `spawn`'s options support it directly and correctly.
|
|
572
|
+
const { spawn } = require_("node:child_process") as typeof import("node:child_process");
|
|
557
573
|
return new Promise((resolve) => {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
574
|
+
let settled = false;
|
|
575
|
+
let buffered = "";
|
|
576
|
+
let truncated = false;
|
|
577
|
+
const child = spawn(command, { shell: true, detached: true, ...(cwd ? { cwd } : {}) });
|
|
578
|
+
|
|
579
|
+
const append = (chunk: Buffer): void => {
|
|
580
|
+
if (truncated) return;
|
|
581
|
+
buffered += chunk.toString("utf8");
|
|
582
|
+
if (buffered.length > GATE_MAX_BUFFER_BYTES) {
|
|
583
|
+
buffered = buffered.slice(0, GATE_MAX_BUFFER_BYTES);
|
|
584
|
+
truncated = true;
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
child.stdout?.on("data", append);
|
|
588
|
+
child.stderr?.on("data", append);
|
|
589
|
+
|
|
590
|
+
const finish = (result: { passed: boolean; output: string }): void => {
|
|
591
|
+
if (settled) return;
|
|
592
|
+
settled = true;
|
|
593
|
+
clearTimeout(timer);
|
|
594
|
+
resolve(result);
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
child.on("error", (error) => finish({ passed: false, output: error.message.slice(0, GATE_OUTPUT_LIMIT) }));
|
|
598
|
+
child.on("close", (code) => {
|
|
599
|
+
const output = buffered.trim().slice(0, GATE_OUTPUT_LIMIT);
|
|
600
|
+
finish({ passed: code === 0, output: output || (code === 0 ? "ok" : `command exited with code ${code}`) });
|
|
564
601
|
});
|
|
602
|
+
|
|
603
|
+
const timer = setTimeout(() => {
|
|
604
|
+
if (settled) return;
|
|
605
|
+
if (child.pid !== undefined) {
|
|
606
|
+
try {
|
|
607
|
+
process.kill(-child.pid, "SIGKILL");
|
|
608
|
+
} catch {
|
|
609
|
+
child.kill("SIGKILL");
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
finish({ passed: false, output: `gate command timed out after ${timeout}ms` });
|
|
613
|
+
}, timeout);
|
|
565
614
|
});
|
|
566
615
|
}
|
|
567
616
|
|
|
@@ -599,7 +648,7 @@ export async function runGatesAsync(db: Db, artifactId: string, options: GateRun
|
|
|
599
648
|
const command = gate.type === "test" ? `npx vitest run ${gate.target} --reporter=dot` : gate.target;
|
|
600
649
|
const configuredTimeout = gate.type === "test" ? GATE_TEST_TIMEOUT_MS : GATE_COMMAND_TIMEOUT_MS;
|
|
601
650
|
const timeout = remainingMs === undefined ? configuredTimeout : Math.max(1, Math.min(configuredTimeout, remainingMs));
|
|
602
|
-
const executed = await executeGateCommand(command, timeout);
|
|
651
|
+
const executed = await executeGateCommand(command, timeout, options.cwd);
|
|
603
652
|
results.push({
|
|
604
653
|
gate,
|
|
605
654
|
passed: executed.passed && (gate.expect ? executed.output.includes(gate.expect) : true),
|
package/src/ports/gate-runner.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { GateResult, GateRunOptions } from "../domain/gate.ts";
|
|
2
2
|
|
|
3
3
|
export interface GateRunner {
|
|
4
|
-
run(artifactId: string): GateResult[];
|
|
4
|
+
run(artifactId: string, options?: GateRunOptions): GateResult[];
|
|
5
5
|
runAsync(artifactId: string, options?: GateRunOptions): Promise<GateResult[]>;
|
|
6
6
|
}
|
package/src/task-service.ts
CHANGED
|
@@ -424,7 +424,7 @@ export class Tasks {
|
|
|
424
424
|
const attemptId = crypto.randomUUID();
|
|
425
425
|
this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
|
|
426
426
|
const checklist = this.reviewChecklist(task);
|
|
427
|
-
const results = this.gates.run(id);
|
|
427
|
+
const results = this.gates.run(id, { cwd: this.scopes.get(id)?.projectRoot });
|
|
428
428
|
return this.resolveCompletion(id, attemptId, results, checklist, context, options);
|
|
429
429
|
}
|
|
430
430
|
|
|
@@ -434,14 +434,17 @@ export class Tasks {
|
|
|
434
434
|
const attemptId = crypto.randomUUID();
|
|
435
435
|
this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
|
|
436
436
|
const checklist = this.reviewChecklist(task);
|
|
437
|
-
|
|
437
|
+
// project_root, never the daemon's own inherited process cwd -- see GateRunOptions.cwd's doc
|
|
438
|
+
// comment for the real incident this fixes (a command gate once tested the daemon's entire
|
|
439
|
+
// home directory instead of the task's project and crashed the bun process outright).
|
|
440
|
+
const results = await this.gates.runAsync(id, { deadlineMs: options.gateDeadlineMs, cwd: this.scopes.get(id)?.projectRoot });
|
|
438
441
|
this.requireReview(id);
|
|
439
442
|
return this.resolveCompletion(id, attemptId, results, checklist, context, options);
|
|
440
443
|
}
|
|
441
444
|
|
|
442
445
|
async runGates(id: string, context: TaskEventContext = {}): Promise<GateResult[]> {
|
|
443
446
|
this.require(id);
|
|
444
|
-
const results = await this.gates.runAsync(id);
|
|
447
|
+
const results = await this.gates.runAsync(id, { cwd: this.scopes.get(id)?.projectRoot });
|
|
445
448
|
this.events.atomic(() => this.appendEvent({ taskId: id, type: "gates_evaluated", evidence: { gates: results, result: results.every((gate) => gate.passed) ? "passed" : "failed" } }, context));
|
|
446
449
|
return results;
|
|
447
450
|
}
|