@danypops/papyrus 0.5.0 → 0.7.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/README.md +35 -5
- package/extension/src/domain-tools.ts +23 -10
- package/extension/src/index.ts +19 -8
- package/extension/src/skills.ts +1 -0
- package/extension/src/task-widget.ts +2 -1
- package/extension/src/tasks.ts +56 -9
- package/package.json +1 -1
- package/src/adapters/sqlite-gate-runner.ts +3 -3
- package/src/adapters/sqlite-task-scope-store.ts +59 -0
- package/src/cli.ts +78 -11
- package/src/constants.ts +18 -1
- package/src/daemon.ts +20 -4
- package/src/db.ts +38 -1
- package/src/domain/gate.ts +5 -0
- package/src/domain/task-event.ts +3 -0
- package/src/domain/task-scope.ts +39 -0
- package/src/log.ts +6 -0
- package/src/ops.ts +18 -7
- package/src/ports/gate-runner.ts +2 -2
- package/src/ports/task-scope-store.ts +40 -0
- package/src/service.ts +62 -10
- package/src/skill-execution.ts +16 -10
- package/src/task-automation.ts +188 -0
- package/src/task-context.ts +4 -2
- package/src/task-service.ts +128 -11
package/src/service.ts
CHANGED
|
@@ -5,14 +5,18 @@ import { SQLiteArtifactStore } from "./adapters/sqlite-artifact-store.ts";
|
|
|
5
5
|
import { SQLiteGateRunner } from "./adapters/sqlite-gate-runner.ts";
|
|
6
6
|
import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
|
|
7
7
|
import { SQLiteTaskEventStore } from "./adapters/sqlite-task-event-store.ts";
|
|
8
|
+
import { SQLiteTaskScopeStore } from "./adapters/sqlite-task-scope-store.ts";
|
|
8
9
|
import type { CreateArtifactInput } from "./domain/artifact.ts";
|
|
9
10
|
import type { Checklist } from "./domain/checklist.ts";
|
|
10
11
|
import type { TaskEventContext, TaskEventDirection } from "./domain/task-event.ts";
|
|
12
|
+
import type { TaskViewMode } from "./domain/task-scope.ts";
|
|
11
13
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
12
14
|
import type { GateRunner } from "./ports/gate-runner.ts";
|
|
13
15
|
import type { TaskEventStore } from "./ports/task-event-store.ts";
|
|
16
|
+
import type { TaskScopeStore } from "./ports/task-scope-store.ts";
|
|
14
17
|
import { projectTaskExecution } from "./task-execution.ts";
|
|
15
18
|
import { Tasks, type TaskStatus } from "./task-service.ts";
|
|
19
|
+
import { TaskAutomationReconciler, taskAutomationSettings, type TaskAutomationSettings } from "./task-automation.ts";
|
|
16
20
|
import {
|
|
17
21
|
createArtifactTemplate,
|
|
18
22
|
createDocument,
|
|
@@ -40,6 +44,8 @@ import { instantiateSkillWorkflow } from "./skill-execution.ts";
|
|
|
40
44
|
|
|
41
45
|
export const EXPECTED_OPERATION_NAMES = [
|
|
42
46
|
"system.migrate",
|
|
47
|
+
"automation.status",
|
|
48
|
+
"automation.reconcile",
|
|
43
49
|
"artifact.create",
|
|
44
50
|
"artifact.query",
|
|
45
51
|
"artifact.show",
|
|
@@ -54,6 +60,9 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
54
60
|
"tasks.plan",
|
|
55
61
|
"tasks.show",
|
|
56
62
|
"tasks.history",
|
|
63
|
+
"tasks.scope",
|
|
64
|
+
"tasks.set_scope",
|
|
65
|
+
"tasks.assign_project",
|
|
57
66
|
"tasks.active",
|
|
58
67
|
"tasks.focus",
|
|
59
68
|
"tasks.start",
|
|
@@ -61,6 +70,7 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
61
70
|
"tasks.complete",
|
|
62
71
|
"tasks.run_gates",
|
|
63
72
|
"tasks.set_checklist",
|
|
73
|
+
"tasks.set_automation",
|
|
64
74
|
"tasks.context",
|
|
65
75
|
"tasks.reject",
|
|
66
76
|
"tasks.retry",
|
|
@@ -144,7 +154,9 @@ function handlers(
|
|
|
144
154
|
artifacts: ArtifactStore,
|
|
145
155
|
gates: GateRunner,
|
|
146
156
|
tasks: Tasks,
|
|
157
|
+
automation: TaskAutomationReconciler,
|
|
147
158
|
events: TaskEventStore,
|
|
159
|
+
scopes: TaskScopeStore,
|
|
148
160
|
migrate: () => unknown,
|
|
149
161
|
): Record<OperationName, OperationHandler> {
|
|
150
162
|
const eventContext = (input: OperationInput): TaskEventContext => ({
|
|
@@ -161,9 +173,14 @@ function handlers(
|
|
|
161
173
|
status: optionalString(input, "status"),
|
|
162
174
|
text: optionalString(input, "text"),
|
|
163
175
|
limit: optionalNumber(input, "limit"),
|
|
176
|
+
projectRoot: string(input, "project_root"),
|
|
177
|
+
scope: optionalString(input, "scope") as TaskViewMode | undefined,
|
|
178
|
+
rootTaskId: optionalString(input, "root_task_id"),
|
|
164
179
|
});
|
|
165
180
|
return {
|
|
166
181
|
"system.migrate": () => migrate(),
|
|
182
|
+
"automation.status": () => automation.status(),
|
|
183
|
+
"automation.reconcile": () => automation.reconcile(),
|
|
167
184
|
"artifact.create": (input) => {
|
|
168
185
|
const normalized = normalizeCreateInput(input);
|
|
169
186
|
if (normalized.kind !== "task") return artifacts.create(normalized);
|
|
@@ -176,6 +193,8 @@ function handlers(
|
|
|
176
193
|
labels: normalized.labels,
|
|
177
194
|
extra: normalized.extra,
|
|
178
195
|
templateId: normalized.templateId,
|
|
196
|
+
projectRoot: string(input, "project_root"),
|
|
197
|
+
projectSource: "cwd",
|
|
179
198
|
}, eventContextFor(input, "artifact-api"));
|
|
180
199
|
},
|
|
181
200
|
"artifact.query": (input) => artifacts.query(input),
|
|
@@ -211,7 +230,7 @@ function handlers(
|
|
|
211
230
|
? tasks.runGates(id, eventContextFor(input, "gates-api"))
|
|
212
231
|
: gates.runAsync(id);
|
|
213
232
|
},
|
|
214
|
-
"rules.injectable": () => listInjectableRules(artifacts, tasks.active()?.id)
|
|
233
|
+
"rules.injectable": (input) => listInjectableRules(artifacts, tasks.active(taskFilter(input))?.id)
|
|
215
234
|
.map(({ id, title, body, extra }) => ({ id, title, body, extra })),
|
|
216
235
|
"tasks.create": (input) => tasks.create({
|
|
217
236
|
title: string(input, "title"),
|
|
@@ -224,6 +243,8 @@ function handlers(
|
|
|
224
243
|
templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
|
|
225
244
|
parentId: optionalString(input, "parent_id") ?? optionalString(input, "parentId"),
|
|
226
245
|
dependsOn: (input["depends_on"] ?? input["dependsOn"]) as string[] | undefined,
|
|
246
|
+
projectRoot: string(input, "project_root"),
|
|
247
|
+
projectSource: "cwd",
|
|
227
248
|
}, eventContext(input)),
|
|
228
249
|
"tasks.list": (input) => tasks.list(taskFilter(input)),
|
|
229
250
|
"tasks.graph": (input) => tasks.graph(taskFilter(input)),
|
|
@@ -234,14 +255,29 @@ function handlers(
|
|
|
234
255
|
cursor: optionalNumber(input, "cursor"),
|
|
235
256
|
direction: optionalString(input, "direction") as TaskEventDirection | undefined,
|
|
236
257
|
}),
|
|
237
|
-
"tasks.
|
|
258
|
+
"tasks.scope": (input) => tasks.scopeSelection(string(input, "project_root")),
|
|
259
|
+
"tasks.set_scope": (input) => tasks.setView(
|
|
260
|
+
string(input, "project_root"),
|
|
261
|
+
string(input, "scope") as TaskViewMode,
|
|
262
|
+
optionalString(input, "root_task_id"),
|
|
263
|
+
),
|
|
264
|
+
"tasks.assign_project": (input) => tasks.assignProject(
|
|
265
|
+
string(input, "id"),
|
|
266
|
+
string(input, "project_root"),
|
|
267
|
+
eventContext(input),
|
|
268
|
+
),
|
|
269
|
+
"tasks.active": (input) => tasks.active(taskFilter(input)),
|
|
238
270
|
"tasks.focus": (input) => tasks.focus(string(input, "id")),
|
|
239
271
|
"tasks.start": (input) => tasks.transition(string(input, "id"), "start", eventContext(input)),
|
|
240
272
|
"tasks.submit": (input) => tasks.transition(string(input, "id"), "submit", eventContext(input)),
|
|
241
273
|
"tasks.complete": (input) => tasks.completeAsync(string(input, "id"), eventContext(input)),
|
|
242
274
|
"tasks.run_gates": (input) => tasks.runGates(string(input, "id"), eventContext(input)),
|
|
243
275
|
"tasks.set_checklist": (input) => tasks.setChecklist(string(input, "id"), input["checklist"] as Checklist),
|
|
244
|
-
"tasks.
|
|
276
|
+
"tasks.set_automation": (input) => {
|
|
277
|
+
if (typeof input["enabled"] !== "boolean") throw new Error("enabled must be a boolean");
|
|
278
|
+
return tasks.setAutomation(string(input, "id"), input["enabled"], eventContext(input));
|
|
279
|
+
},
|
|
280
|
+
"tasks.context": (input) => taskContext(artifacts, tasks.active()?.id, new Set(tasks.list(taskFilter(input)).map((task) => task.id))),
|
|
245
281
|
"tasks.reject": (input) => tasks.transition(string(input, "id"), "reject", eventContext(input)),
|
|
246
282
|
"tasks.retry": (input) => tasks.transition(string(input, "id"), "retry", eventContext(input)),
|
|
247
283
|
"tasks.cancel": (input) => tasks.transition(string(input, "id"), "cancel", eventContext(input)),
|
|
@@ -286,21 +322,37 @@ function handlers(
|
|
|
286
322
|
"skills.run": (input) => instantiateSkillWorkflow(artifacts, string(input, "id"), {
|
|
287
323
|
runId: optionalString(input, "run_id") ?? optionalString(input, "runId"),
|
|
288
324
|
arguments: input["arguments"] as Record<string, unknown> | undefined,
|
|
289
|
-
}, { events, context: eventContextFor(input, "skill-run") }),
|
|
325
|
+
}, { events, scopes, projectRoot: string(input, "project_root"), context: eventContextFor(input, "skill-run") }),
|
|
290
326
|
"skills.enable": (input) => transitionSkill(artifacts, string(input, "id"), "enable"),
|
|
291
327
|
"skills.disable": (input) => transitionSkill(artifacts, string(input, "id"), "disable"),
|
|
292
|
-
"skills.instantiate": (input) =>
|
|
328
|
+
"skills.instantiate": (input) => {
|
|
329
|
+
const templateId = string(input, "template_id");
|
|
330
|
+
const template = artifacts.get(templateId);
|
|
331
|
+
if (template?.extra["targetKind"] !== "task") return instantiateTemplate(artifacts, templateId, normalizeCreateInput(input));
|
|
332
|
+
return tasks.create({
|
|
333
|
+
title: optionalString(input, "title") as string,
|
|
334
|
+
body: optionalString(input, "body"),
|
|
335
|
+
status: optionalString(input, "status") as TaskStatus | undefined,
|
|
336
|
+
labels: input["labels"] as string[] | undefined,
|
|
337
|
+
extra: input["extra"] as Record<string, unknown> | undefined,
|
|
338
|
+
templateId,
|
|
339
|
+
projectRoot: string(input, "project_root"),
|
|
340
|
+
projectSource: "cwd",
|
|
341
|
+
}, eventContextFor(input, "template-instantiation"));
|
|
342
|
+
},
|
|
293
343
|
};
|
|
294
344
|
}
|
|
295
345
|
|
|
296
|
-
export function createPapyrusService(path: string): PapyrusService {
|
|
346
|
+
export function createPapyrusService(path: string, options: { automation?: TaskAutomationSettings } = {}): PapyrusService {
|
|
297
347
|
const db = openDb(path);
|
|
298
348
|
const artifacts = new SQLiteArtifactStore(db);
|
|
299
349
|
const gates = new SQLiteGateRunner(db);
|
|
300
350
|
const focus = new SQLiteTaskFocusStore(db);
|
|
301
351
|
const events = new SQLiteTaskEventStore(db);
|
|
302
|
-
const
|
|
303
|
-
const
|
|
352
|
+
const scopes = new SQLiteTaskScopeStore(db);
|
|
353
|
+
const tasks = new Tasks(artifacts, gates, focus, events, scopes);
|
|
354
|
+
const automation = new TaskAutomationReconciler(tasks, options.automation ?? taskAutomationSettings({}));
|
|
355
|
+
const registry = handlers(artifacts, gates, tasks, automation, events, scopes, () => migrateDb(db));
|
|
304
356
|
const state = (): SchemaState => {
|
|
305
357
|
const current = schemaVersion(db);
|
|
306
358
|
return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
|
|
@@ -311,8 +363,8 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
311
363
|
async execute(operation, input = {}) {
|
|
312
364
|
const handler = registry[operation as OperationName];
|
|
313
365
|
if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
|
|
314
|
-
if (operation !== "system.migrate" && state().migrationRequired) {
|
|
315
|
-
throw new MigrationRequiredError("database migration required; run `papyrus migrate task-
|
|
366
|
+
if (operation !== "system.migrate" && operation !== "automation.status" && state().migrationRequired) {
|
|
367
|
+
throw new MigrationRequiredError("database migration required; run `papyrus migrate task-scope`");
|
|
316
368
|
}
|
|
317
369
|
return handler(input);
|
|
318
370
|
},
|
package/src/skill-execution.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
12
12
|
import type { TaskEventContext } from "./domain/task-event.ts";
|
|
13
13
|
import type { TaskEventStore } from "./ports/task-event-store.ts";
|
|
14
|
+
import type { TaskScopeStore } from "./ports/task-scope-store.ts";
|
|
15
|
+
import { normalizeProjectRoot } from "./domain/task-scope.ts";
|
|
14
16
|
import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
|
|
15
17
|
import { projectTaskExecution, type TaskExecutionPlan } from "./task-execution.ts";
|
|
16
18
|
import type { TaskGraph, TaskNode, TaskStatus } from "./task-service.ts";
|
|
@@ -117,9 +119,10 @@ export function instantiateSkillWorkflow(
|
|
|
117
119
|
artifacts: ArtifactStore,
|
|
118
120
|
skillId: string,
|
|
119
121
|
input: InstantiateSkillWorkflowInput = {},
|
|
120
|
-
history?: { events: TaskEventStore; context?: TaskEventContext },
|
|
122
|
+
history?: { events: TaskEventStore; scopes: TaskScopeStore; projectRoot: string; context?: TaskEventContext },
|
|
121
123
|
): SkillWorkflowRunResult {
|
|
122
124
|
const { definition } = requireWorkflowSkill(artifacts, skillId);
|
|
125
|
+
const projectRoot = history ? normalizeProjectRoot(history.projectRoot) : undefined;
|
|
123
126
|
const arguments_ = resolveSkillArguments(definition, input.arguments);
|
|
124
127
|
const rendered = renderDefinition(definition, arguments_);
|
|
125
128
|
const runId = normalizeRunId(skillId, input.runId);
|
|
@@ -175,15 +178,18 @@ export function instantiateSkillWorkflow(
|
|
|
175
178
|
labels: withRunLabel(blueprint.labels, runId),
|
|
176
179
|
extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
|
|
177
180
|
});
|
|
178
|
-
if (history)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
181
|
+
if (history) {
|
|
182
|
+
history.scopes.assign(task.id, projectRoot, "cwd");
|
|
183
|
+
history.events.append({
|
|
184
|
+
taskId: task.id,
|
|
185
|
+
type: "created",
|
|
186
|
+
actor: history.context?.actor ?? "system",
|
|
187
|
+
source: history.context?.source ?? "skill-run",
|
|
188
|
+
toStatus: task.status as TaskStatus,
|
|
189
|
+
...(history.context?.sessionId === undefined ? {} : { sessionId: history.context.sessionId }),
|
|
190
|
+
...(history.context?.reason === undefined ? {} : { reason: history.context.reason }),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
187
193
|
return task;
|
|
188
194
|
});
|
|
189
195
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TASK_AUTOMATION_ERROR_ID_MAX_LENGTH,
|
|
3
|
+
TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH,
|
|
4
|
+
TASK_AUTOMATION_GATE_CONCURRENCY,
|
|
5
|
+
TASK_AUTOMATION_HARD_MAX_RUNTIME_MS,
|
|
6
|
+
TASK_AUTOMATION_HARD_MAX_TASKS_PER_SWEEP,
|
|
7
|
+
TASK_AUTOMATION_INTERVAL_MS,
|
|
8
|
+
TASK_AUTOMATION_MAX_CANDIDATE_SCAN,
|
|
9
|
+
TASK_AUTOMATION_MAX_GATE_CONCURRENCY,
|
|
10
|
+
TASK_AUTOMATION_MAX_INTERVAL_MS,
|
|
11
|
+
TASK_AUTOMATION_MAX_RUNTIME_MS,
|
|
12
|
+
TASK_AUTOMATION_MAX_TASKS_PER_SWEEP,
|
|
13
|
+
TASK_AUTOMATION_MIN_INTERVAL_MS,
|
|
14
|
+
} from "./constants.ts";
|
|
15
|
+
import type { Artifact } from "./domain/artifact.ts";
|
|
16
|
+
import { projectTaskExecution } from "./task-execution.ts";
|
|
17
|
+
import type { Tasks } from "./task-service.ts";
|
|
18
|
+
|
|
19
|
+
export interface TaskAutomationSettings {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
intervalMs: number;
|
|
22
|
+
maxTasksPerSweep: number;
|
|
23
|
+
gateConcurrency: number;
|
|
24
|
+
maxRuntimeMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TaskAutomationResult {
|
|
28
|
+
skipped?: "disabled" | "in-flight";
|
|
29
|
+
examined: number;
|
|
30
|
+
completed: number;
|
|
31
|
+
rejected: number;
|
|
32
|
+
started: number;
|
|
33
|
+
errors: Array<{ taskId: string; message: string }>;
|
|
34
|
+
timedOut: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function boundedInteger(
|
|
38
|
+
env: Record<string, string | undefined>,
|
|
39
|
+
name: string,
|
|
40
|
+
fallback: number,
|
|
41
|
+
minimum: number,
|
|
42
|
+
maximum: number,
|
|
43
|
+
): number {
|
|
44
|
+
const source = env[name];
|
|
45
|
+
if (source === undefined || source === "") return fallback;
|
|
46
|
+
const value = Number(source);
|
|
47
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
|
48
|
+
throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`);
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function taskAutomationSettings(env: Record<string, string | undefined> = process.env): TaskAutomationSettings {
|
|
54
|
+
const enabled = env["PAPYRUS_AUTOMATION_ENABLED"] === "1";
|
|
55
|
+
if (env["PAPYRUS_AUTOMATION_ENABLED"] !== undefined && env["PAPYRUS_AUTOMATION_ENABLED"] !== "0" && !enabled) {
|
|
56
|
+
throw new Error("PAPYRUS_AUTOMATION_ENABLED must be 0 or 1");
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
enabled,
|
|
60
|
+
intervalMs: boundedInteger(env, "PAPYRUS_AUTOMATION_INTERVAL_MS", TASK_AUTOMATION_INTERVAL_MS, TASK_AUTOMATION_MIN_INTERVAL_MS, TASK_AUTOMATION_MAX_INTERVAL_MS),
|
|
61
|
+
maxTasksPerSweep: boundedInteger(env, "PAPYRUS_AUTOMATION_MAX_TASKS", TASK_AUTOMATION_MAX_TASKS_PER_SWEEP, 1, TASK_AUTOMATION_HARD_MAX_TASKS_PER_SWEEP),
|
|
62
|
+
gateConcurrency: boundedInteger(env, "PAPYRUS_AUTOMATION_GATE_CONCURRENCY", TASK_AUTOMATION_GATE_CONCURRENCY, 1, TASK_AUTOMATION_MAX_GATE_CONCURRENCY),
|
|
63
|
+
maxRuntimeMs: boundedInteger(env, "PAPYRUS_AUTOMATION_MAX_RUNTIME_MS", TASK_AUTOMATION_MAX_RUNTIME_MS, 1, TASK_AUTOMATION_HARD_MAX_RUNTIME_MS),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function automationEnabled(task: Artifact): boolean {
|
|
68
|
+
const automation = task.extra["automation"];
|
|
69
|
+
return typeof automation === "object"
|
|
70
|
+
&& automation !== null
|
|
71
|
+
&& !Array.isArray(automation)
|
|
72
|
+
&& (automation as Record<string, unknown>)["enabled"] === true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function emptyResult(skipped?: TaskAutomationResult["skipped"]): TaskAutomationResult {
|
|
76
|
+
return { ...(skipped ? { skipped } : {}), examined: 0, completed: 0, rejected: 0, started: 0, errors: [], timedOut: false };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function boundedError(taskId: string, error: unknown): TaskAutomationResult["errors"][number] {
|
|
80
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
81
|
+
return {
|
|
82
|
+
taskId: taskId.slice(0, TASK_AUTOMATION_ERROR_ID_MAX_LENGTH),
|
|
83
|
+
message: message.slice(0, TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface TaskAutomationScheduler {
|
|
88
|
+
setInterval(callback: () => void, intervalMs: number): unknown;
|
|
89
|
+
clearInterval(handle: unknown): void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SYSTEM_SCHEDULER: TaskAutomationScheduler = {
|
|
93
|
+
setInterval: (callback, intervalMs) => setInterval(callback, intervalMs),
|
|
94
|
+
clearInterval: (handle) => clearInterval(handle as ReturnType<typeof setInterval>),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function scheduleTaskAutomation(
|
|
98
|
+
settings: TaskAutomationSettings,
|
|
99
|
+
sweep: () => Promise<unknown>,
|
|
100
|
+
onError: (error: unknown) => void,
|
|
101
|
+
scheduler: TaskAutomationScheduler = SYSTEM_SCHEDULER,
|
|
102
|
+
): () => void {
|
|
103
|
+
if (!settings.enabled) return () => {};
|
|
104
|
+
const handle = scheduler.setInterval(() => { void sweep().catch(onError); }, settings.intervalMs);
|
|
105
|
+
return () => scheduler.clearInterval(handle);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class TaskAutomationReconciler {
|
|
109
|
+
private inFlight = false;
|
|
110
|
+
|
|
111
|
+
constructor(
|
|
112
|
+
private readonly tasks: Tasks,
|
|
113
|
+
private readonly settings: TaskAutomationSettings,
|
|
114
|
+
private readonly now: () => number = () => Date.now(),
|
|
115
|
+
) {}
|
|
116
|
+
|
|
117
|
+
status(): TaskAutomationSettings & { inFlight: boolean } {
|
|
118
|
+
return { ...this.settings, inFlight: this.inFlight };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async reconcile(): Promise<TaskAutomationResult> {
|
|
122
|
+
if (!this.settings.enabled) return emptyResult("disabled");
|
|
123
|
+
if (this.inFlight) return emptyResult("in-flight");
|
|
124
|
+
this.inFlight = true;
|
|
125
|
+
try { return await this.runSweep(); }
|
|
126
|
+
finally { this.inFlight = false; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async runSweep(): Promise<TaskAutomationResult> {
|
|
130
|
+
const result = emptyResult();
|
|
131
|
+
const deadline = this.now() + this.settings.maxRuntimeMs;
|
|
132
|
+
const candidates = this.tasks.list({ status: "review", limit: TASK_AUTOMATION_MAX_CANDIDATE_SCAN })
|
|
133
|
+
.filter(automationEnabled)
|
|
134
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
135
|
+
.slice(0, this.settings.maxTasksPerSweep);
|
|
136
|
+
const completedIds = new Set<string>();
|
|
137
|
+
|
|
138
|
+
for (let offset = 0; offset < candidates.length; offset += this.settings.gateConcurrency) {
|
|
139
|
+
if (this.now() >= deadline) { result.timedOut = true; break; }
|
|
140
|
+
const batch = candidates.slice(offset, offset + this.settings.gateConcurrency);
|
|
141
|
+
await Promise.all(batch.map(async (task) => {
|
|
142
|
+
result.examined += 1;
|
|
143
|
+
try {
|
|
144
|
+
const completion = await this.tasks.completeAsync(task.id, {
|
|
145
|
+
actor: "daemon",
|
|
146
|
+
source: "automation-reconciler",
|
|
147
|
+
reason: "automation-enabled review reconciliation",
|
|
148
|
+
}, { focusSuccessor: false, gateDeadlineMs: deadline });
|
|
149
|
+
if (completion.completed) {
|
|
150
|
+
result.completed += 1;
|
|
151
|
+
completedIds.add(task.id);
|
|
152
|
+
} else result.rejected += 1;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
result.errors.push(boundedError(task.id, error));
|
|
155
|
+
}
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let remaining = Math.max(0, this.settings.maxTasksPerSweep - result.examined);
|
|
160
|
+
if (remaining > 0 && completedIds.size > 0 && this.now() < deadline) {
|
|
161
|
+
let graph: ReturnType<Tasks["graph"]>;
|
|
162
|
+
try { graph = this.tasks.graph(); }
|
|
163
|
+
catch (error) {
|
|
164
|
+
result.errors.push(boundedError("graph", error));
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
const stateById = new Map(projectTaskExecution(graph).nodes.map((node) => [node.id, node.state]));
|
|
168
|
+
for (const node of [...graph.nodes].sort((left, right) => left.task.id.localeCompare(right.task.id))) {
|
|
169
|
+
if (remaining === 0 || this.now() >= deadline) break;
|
|
170
|
+
if (node.task.status !== "todo" || !automationEnabled(node.task) || stateById.get(node.task.id) !== "ready") continue;
|
|
171
|
+
if (!node.dependencyIds.some((id) => completedIds.has(id))) continue;
|
|
172
|
+
try {
|
|
173
|
+
this.tasks.transition(node.task.id, "start", {
|
|
174
|
+
actor: "daemon",
|
|
175
|
+
source: "automation-reconciler",
|
|
176
|
+
reason: "automation-enabled successor became ready",
|
|
177
|
+
});
|
|
178
|
+
result.started += 1;
|
|
179
|
+
remaining -= 1;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
result.errors.push(boundedError(node.task.id, error));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (this.now() >= deadline) result.timedOut = true;
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/task-context.ts
CHANGED
|
@@ -34,8 +34,10 @@ function renderCurrent(task: Artifact): string[] {
|
|
|
34
34
|
];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function taskContext(artifacts: ArtifactStore, activeTaskId?: string): string | null {
|
|
38
|
-
const tasks = artifacts.query({ kind: "task" })
|
|
37
|
+
export function taskContext(artifacts: ArtifactStore, activeTaskId?: string, taskIds?: Set<string>): string | null {
|
|
38
|
+
const tasks = artifacts.query({ kind: "task" })
|
|
39
|
+
.filter((task) => taskIds === undefined || taskIds.has(task.id))
|
|
40
|
+
.sort((left, right) => left.updated_at.localeCompare(right.updated_at));
|
|
39
41
|
const open = tasks.filter((task) => task.status !== "done" && task.status !== "canceled");
|
|
40
42
|
if (open.length === 0) return null;
|
|
41
43
|
|