@hasna/loops 0.4.5 → 0.4.7
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/CHANGELOG.md +40 -0
- package/README.md +2 -2
- package/dist/api/index.js +19 -10
- package/dist/cli/index.js +613 -230
- package/dist/daemon/index.js +367 -170
- package/dist/index.js +413 -182
- package/dist/lib/executor.d.ts +14 -1
- package/dist/lib/mode.js +1 -1
- package/dist/lib/route/types.d.ts +6 -0
- package/dist/lib/storage/index.js +57 -5
- package/dist/lib/storage/sqlite.js +57 -5
- package/dist/lib/store.d.ts +7 -0
- package/dist/lib/store.js +57 -5
- package/dist/lib/template-kit.d.ts +6 -0
- package/dist/lib/templates.d.ts +10 -0
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +371 -173
- package/dist/runner/index.d.ts +3 -1
- package/dist/runner/index.js +180 -19
- package/dist/sdk/index.js +370 -174
- package/docs/USAGE.md +2 -2
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -726,7 +726,7 @@ function buildAgentInvocation(target) {
|
|
|
726
726
|
if (target.model)
|
|
727
727
|
args.push("--model", target.model);
|
|
728
728
|
args.push(...target.extraArgs ?? []);
|
|
729
|
-
args.push("agent", "start");
|
|
729
|
+
args.push("agent", "start", "--json");
|
|
730
730
|
if (target.cwd)
|
|
731
731
|
args.push("--cwd", target.cwd);
|
|
732
732
|
args.push(target.prompt);
|
|
@@ -1493,11 +1493,14 @@ function ensurePrivateStorePath(file) {
|
|
|
1493
1493
|
class Store {
|
|
1494
1494
|
db;
|
|
1495
1495
|
rootDir;
|
|
1496
|
+
memoryRootDir;
|
|
1496
1497
|
constructor(path) {
|
|
1497
1498
|
const file = path ?? dbPath();
|
|
1498
1499
|
if (file !== ":memory:")
|
|
1499
1500
|
ensurePrivateStorePath(file);
|
|
1500
1501
|
this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname2(file);
|
|
1502
|
+
if (file === ":memory:")
|
|
1503
|
+
this.memoryRootDir = this.rootDir;
|
|
1501
1504
|
this.db = new Database(file);
|
|
1502
1505
|
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
1503
1506
|
this.db.exec("PRAGMA busy_timeout = 5000;");
|
|
@@ -1950,9 +1953,12 @@ class Store {
|
|
|
1950
1953
|
const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
|
|
1951
1954
|
if (rows.length === 0)
|
|
1952
1955
|
throw new LoopNotFoundError(idOrName);
|
|
1953
|
-
if (rows.length
|
|
1956
|
+
if (rows.length === 1)
|
|
1957
|
+
return rowToLoop(rows[0]);
|
|
1958
|
+
const active = this.db.query("SELECT * FROM loops WHERE name = ? AND archived_at IS NULL ORDER BY created_at DESC LIMIT 2").all(idOrName);
|
|
1959
|
+
if (active.length !== 1)
|
|
1954
1960
|
throw new AmbiguousNameError(idOrName);
|
|
1955
|
-
return rowToLoop(
|
|
1961
|
+
return rowToLoop(active[0]);
|
|
1956
1962
|
}
|
|
1957
1963
|
requireLoop(idOrName) {
|
|
1958
1964
|
return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
|
|
@@ -2293,6 +2299,7 @@ class Store {
|
|
|
2293
2299
|
return this.transact(() => {
|
|
2294
2300
|
const loop = this.requireLoop(idOrName);
|
|
2295
2301
|
this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
|
|
2302
|
+
this.db.query("DELETE FROM loop_runs WHERE loop_id = ?").run(loop.id);
|
|
2296
2303
|
const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
|
|
2297
2304
|
return res.changes > 0;
|
|
2298
2305
|
});
|
|
@@ -2777,7 +2784,10 @@ class Store {
|
|
|
2777
2784
|
return rowToGoal(row);
|
|
2778
2785
|
}
|
|
2779
2786
|
if (context.sourceType && context.sourceId) {
|
|
2780
|
-
const
|
|
2787
|
+
const terminalPlaceholders = GOAL_TERMINAL.map(() => "?").join(", ");
|
|
2788
|
+
const row = this.db.query(`SELECT * FROM goals
|
|
2789
|
+
WHERE source_type = ? AND source_id = ? AND status NOT IN (${terminalPlaceholders})
|
|
2790
|
+
ORDER BY created_at DESC LIMIT 1`).get(context.sourceType, context.sourceId, ...GOAL_TERMINAL);
|
|
2781
2791
|
if (row)
|
|
2782
2792
|
return rowToGoal(row);
|
|
2783
2793
|
}
|
|
@@ -3197,6 +3207,41 @@ class Store {
|
|
|
3197
3207
|
throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
|
|
3198
3208
|
return run;
|
|
3199
3209
|
}
|
|
3210
|
+
recordWorkflowStepProgress(workflowRunId, stepId, progress, opts = {}) {
|
|
3211
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
3212
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
3213
|
+
try {
|
|
3214
|
+
const res = this.db.query(`UPDATE workflow_step_runs
|
|
3215
|
+
SET stdout=COALESCE($stdout, stdout),
|
|
3216
|
+
stderr=COALESCE($stderr, stderr),
|
|
3217
|
+
updated_at=$updated
|
|
3218
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
|
|
3219
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
3220
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
3221
|
+
))`).run({
|
|
3222
|
+
$workflowRunId: workflowRunId,
|
|
3223
|
+
$stepId: stepId,
|
|
3224
|
+
$stdout: progress.stdout === undefined ? null : scrubbedOrNull(progress.stdout),
|
|
3225
|
+
$stderr: progress.stderr === undefined ? null : scrubbedOrNull(progress.stderr),
|
|
3226
|
+
$updated: now,
|
|
3227
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
3228
|
+
$now: now
|
|
3229
|
+
});
|
|
3230
|
+
if (res.changes === 1) {
|
|
3231
|
+
this.appendWorkflowEvent(workflowRunId, "step_progress", stepId, progress.payload);
|
|
3232
|
+
}
|
|
3233
|
+
this.db.exec("COMMIT");
|
|
3234
|
+
} catch (error) {
|
|
3235
|
+
try {
|
|
3236
|
+
this.db.exec("ROLLBACK");
|
|
3237
|
+
} catch {}
|
|
3238
|
+
throw error;
|
|
3239
|
+
}
|
|
3240
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
3241
|
+
if (!run)
|
|
3242
|
+
throw new Error(`workflow step run not found after progress update: ${workflowRunId}/${stepId}`);
|
|
3243
|
+
return run;
|
|
3244
|
+
}
|
|
3200
3245
|
recoverWorkflowRun(workflowRunId, reason = "workflow run recovered for retry") {
|
|
3201
3246
|
return this.transact(() => {
|
|
3202
3247
|
const now = nowIso();
|
|
@@ -3634,7 +3679,8 @@ class Store {
|
|
|
3634
3679
|
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
3635
3680
|
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
3636
3681
|
))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, claim_token=NULL, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
3637
|
-
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
3682
|
+
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
3683
|
+
WHERE id=$id AND status='running'`).run(params);
|
|
3638
3684
|
const run = this.getRun(id);
|
|
3639
3685
|
if (!run)
|
|
3640
3686
|
throw new Error(`run not found after finalize: ${id}`);
|
|
@@ -4162,12 +4208,18 @@ class Store {
|
|
|
4162
4208
|
}
|
|
4163
4209
|
close() {
|
|
4164
4210
|
this.db.close();
|
|
4211
|
+
if (this.memoryRootDir) {
|
|
4212
|
+
try {
|
|
4213
|
+
rmSync2(this.memoryRootDir, { recursive: true, force: true });
|
|
4214
|
+
} catch {}
|
|
4215
|
+
this.memoryRootDir = undefined;
|
|
4216
|
+
}
|
|
4165
4217
|
}
|
|
4166
4218
|
}
|
|
4167
4219
|
// package.json
|
|
4168
4220
|
var package_default = {
|
|
4169
4221
|
name: "@hasna/loops",
|
|
4170
|
-
version: "0.4.
|
|
4222
|
+
version: "0.4.7",
|
|
4171
4223
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4172
4224
|
type: "module",
|
|
4173
4225
|
main: "dist/index.js",
|
|
@@ -4882,7 +4934,12 @@ function notifySpawn(pid, opts) {
|
|
|
4882
4934
|
if (!pid)
|
|
4883
4935
|
return;
|
|
4884
4936
|
opts.onSpawn?.(pid);
|
|
4885
|
-
|
|
4937
|
+
const startedMs = processStartTimeMs(pid);
|
|
4938
|
+
opts.onSpawnProcess?.({
|
|
4939
|
+
pid,
|
|
4940
|
+
pgid: pid,
|
|
4941
|
+
processStartedAt: startedMs !== undefined ? new Date(startedMs).toISOString() : nowIso()
|
|
4942
|
+
});
|
|
4886
4943
|
}
|
|
4887
4944
|
function shellQuote(value) {
|
|
4888
4945
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
@@ -5207,18 +5264,60 @@ function codewithAgentControlArgs(target, command, agentId) {
|
|
|
5207
5264
|
"agent",
|
|
5208
5265
|
command,
|
|
5209
5266
|
...command === "logs" ? ["--limit", "20"] : [],
|
|
5267
|
+
"--json",
|
|
5210
5268
|
agentId
|
|
5211
5269
|
];
|
|
5212
5270
|
}
|
|
5271
|
+
function extractFirstJsonObject(text) {
|
|
5272
|
+
let depth = 0;
|
|
5273
|
+
let start = -1;
|
|
5274
|
+
let inString = false;
|
|
5275
|
+
let escaped = false;
|
|
5276
|
+
for (let i = 0;i < text.length; i += 1) {
|
|
5277
|
+
const ch = text[i];
|
|
5278
|
+
if (inString) {
|
|
5279
|
+
if (escaped)
|
|
5280
|
+
escaped = false;
|
|
5281
|
+
else if (ch === "\\")
|
|
5282
|
+
escaped = true;
|
|
5283
|
+
else if (ch === '"')
|
|
5284
|
+
inString = false;
|
|
5285
|
+
continue;
|
|
5286
|
+
}
|
|
5287
|
+
if (ch === '"') {
|
|
5288
|
+
inString = true;
|
|
5289
|
+
} else if (ch === "{") {
|
|
5290
|
+
if (depth === 0)
|
|
5291
|
+
start = i;
|
|
5292
|
+
depth += 1;
|
|
5293
|
+
} else if (ch === "}") {
|
|
5294
|
+
if (depth > 0) {
|
|
5295
|
+
depth -= 1;
|
|
5296
|
+
if (depth === 0 && start !== -1)
|
|
5297
|
+
return text.slice(start, i + 1);
|
|
5298
|
+
}
|
|
5299
|
+
}
|
|
5300
|
+
}
|
|
5301
|
+
return;
|
|
5302
|
+
}
|
|
5213
5303
|
function parseJsonOutput(stdout, label) {
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5304
|
+
const raw = stdout || "{}";
|
|
5305
|
+
const candidates = [raw.trim()];
|
|
5306
|
+
const scanned = extractFirstJsonObject(raw);
|
|
5307
|
+
if (scanned && scanned !== raw.trim())
|
|
5308
|
+
candidates.push(scanned);
|
|
5309
|
+
let lastError;
|
|
5310
|
+
for (const candidate of candidates) {
|
|
5311
|
+
try {
|
|
5312
|
+
const value = JSON.parse(candidate);
|
|
5313
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
5314
|
+
throw new Error("not an object");
|
|
5315
|
+
return value;
|
|
5316
|
+
} catch (error) {
|
|
5317
|
+
lastError = error;
|
|
5318
|
+
}
|
|
5221
5319
|
}
|
|
5320
|
+
throw new Error(`${label} did not return JSON${lastError instanceof Error ? `: ${lastError.message}` : ""}`);
|
|
5222
5321
|
}
|
|
5223
5322
|
function recordField(value, key) {
|
|
5224
5323
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
@@ -5237,6 +5336,13 @@ function numberField(value, key) {
|
|
|
5237
5336
|
function codewithAgentStatus(readJson) {
|
|
5238
5337
|
return stringField(recordField(readJson, "agent"), "status") ?? stringField(recordField(readJson, "statusSnapshot"), "status");
|
|
5239
5338
|
}
|
|
5339
|
+
function codewithAgentLastEventSeq(readJson) {
|
|
5340
|
+
const agentSeq = numberField(recordField(readJson, "agent"), "lastEventSeq");
|
|
5341
|
+
const snapshotSeq = numberField(recordField(readJson, "statusSnapshot"), "lastEventSeq");
|
|
5342
|
+
if (agentSeq !== undefined && snapshotSeq !== undefined)
|
|
5343
|
+
return Math.max(agentSeq, snapshotSeq);
|
|
5344
|
+
return agentSeq ?? snapshotSeq;
|
|
5345
|
+
}
|
|
5240
5346
|
function codewithAgentEvidence(startJson, readJson, logsJson) {
|
|
5241
5347
|
const agent = recordField(readJson, "agent") ?? recordField(startJson, "agent");
|
|
5242
5348
|
const statusSnapshot = recordField(readJson, "statusSnapshot");
|
|
@@ -5267,6 +5373,21 @@ function codewithAgentEvidence(startJson, readJson, logsJson) {
|
|
|
5267
5373
|
events
|
|
5268
5374
|
}, null, 2);
|
|
5269
5375
|
}
|
|
5376
|
+
function codewithAgentProgress(readJson) {
|
|
5377
|
+
const agent = recordField(readJson, "agent");
|
|
5378
|
+
const statusSnapshot = recordField(readJson, "statusSnapshot");
|
|
5379
|
+
return {
|
|
5380
|
+
provider: "codewith",
|
|
5381
|
+
agentId: stringField(agent, "agentId"),
|
|
5382
|
+
status: stringField(agent, "status") ?? stringField(statusSnapshot, "status"),
|
|
5383
|
+
summary: stringField(statusSnapshot, "summary"),
|
|
5384
|
+
statusReason: stringField(agent, "statusReason"),
|
|
5385
|
+
threadId: stringField(agent, "threadId"),
|
|
5386
|
+
rolloutPath: stringField(agent, "rolloutPath"),
|
|
5387
|
+
pid: numberField(agent, "pid"),
|
|
5388
|
+
lastEventSeq: codewithAgentLastEventSeq(readJson)
|
|
5389
|
+
};
|
|
5390
|
+
}
|
|
5270
5391
|
function sleep(ms) {
|
|
5271
5392
|
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
5272
5393
|
}
|
|
@@ -5301,6 +5422,7 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
|
|
|
5301
5422
|
if (!agentId) {
|
|
5302
5423
|
return failureResult(startedAt, "codewith agent start did not return agent.agentId", { stdout: start.stdout, stderr: stderr.value() });
|
|
5303
5424
|
}
|
|
5425
|
+
opts.onAgentProgress?.(codewithAgentProgress(startJson));
|
|
5304
5426
|
const stopAgent = async () => {
|
|
5305
5427
|
await spawnCapture(spec.command, codewithAgentControlArgs(target, "stop", agentId), {
|
|
5306
5428
|
cwd: spec.cwd,
|
|
@@ -5343,10 +5465,15 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
|
|
|
5343
5465
|
});
|
|
5344
5466
|
}
|
|
5345
5467
|
const status = codewithAgentStatus(lastReadJson);
|
|
5346
|
-
const fingerprint = JSON.stringify({
|
|
5468
|
+
const fingerprint = JSON.stringify({
|
|
5469
|
+
status,
|
|
5470
|
+
agentLastEventSeq: numberField(recordField(lastReadJson, "agent"), "lastEventSeq"),
|
|
5471
|
+
snapshot: recordField(lastReadJson, "statusSnapshot") ?? null
|
|
5472
|
+
});
|
|
5347
5473
|
if (fingerprint !== lastFingerprint) {
|
|
5348
5474
|
lastFingerprint = fingerprint;
|
|
5349
5475
|
lastProgressAt = Date.now();
|
|
5476
|
+
opts.onAgentProgress?.(codewithAgentProgress(lastReadJson));
|
|
5350
5477
|
}
|
|
5351
5478
|
if (status === "completed" || status === "failed" || status === "cancelled") {
|
|
5352
5479
|
const logs = await spawnCapture(spec.command, codewithAgentControlArgs(target, "logs", agentId), {
|
|
@@ -6347,173 +6474,213 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
6347
6474
|
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
|
|
6348
6475
|
return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), store.listWorkflowStepRuns(run.id), run.error);
|
|
6349
6476
|
}
|
|
6477
|
+
const resumedRunningSteps = store.listWorkflowStepRuns(run.id).filter((step) => step.status === "running");
|
|
6478
|
+
if (resumedRunningSteps.length > 0 && resumedRunningSteps.every((step) => step.pid !== undefined)) {
|
|
6479
|
+
try {
|
|
6480
|
+
store.recoverWorkflowRun(run.id, "workflow run resumed after lease takeover");
|
|
6481
|
+
} catch {}
|
|
6482
|
+
}
|
|
6350
6483
|
const ordered = workflowExecutionOrder(workflow);
|
|
6351
6484
|
const byId = new Map(workflow.steps.map((step) => [step.id, step]));
|
|
6352
6485
|
let blockingError;
|
|
6353
6486
|
let terminalStatus = "succeeded";
|
|
6354
|
-
|
|
6355
|
-
|
|
6356
|
-
|
|
6357
|
-
|
|
6358
|
-
|
|
6359
|
-
|
|
6360
|
-
|
|
6361
|
-
|
|
6362
|
-
|
|
6363
|
-
|
|
6364
|
-
|
|
6365
|
-
|
|
6366
|
-
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
|
|
6370
|
-
const
|
|
6371
|
-
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
|
|
6375
|
-
|
|
6376
|
-
|
|
6377
|
-
|
|
6378
|
-
|
|
6379
|
-
store.
|
|
6380
|
-
|
|
6381
|
-
|
|
6487
|
+
try {
|
|
6488
|
+
for (const step of ordered) {
|
|
6489
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
6490
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
6491
|
+
blockingError = "workflow run was cancelled";
|
|
6492
|
+
break;
|
|
6493
|
+
}
|
|
6494
|
+
const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
6495
|
+
if (pendingTimeout) {
|
|
6496
|
+
terminalStatus = "timed_out";
|
|
6497
|
+
blockingError = pendingTimeout;
|
|
6498
|
+
break;
|
|
6499
|
+
}
|
|
6500
|
+
const existing = store.getWorkflowStepRun(run.id, step.id);
|
|
6501
|
+
if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
|
|
6502
|
+
continue;
|
|
6503
|
+
const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
|
|
6504
|
+
const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
|
|
6505
|
+
const dependencyStep = byId.get(dependencyId);
|
|
6506
|
+
if (dependencyRun?.status === "succeeded")
|
|
6507
|
+
return false;
|
|
6508
|
+
return !dependencyStep?.continueOnFailure;
|
|
6509
|
+
});
|
|
6510
|
+
if (blockedBy) {
|
|
6511
|
+
opts.beforePersist?.();
|
|
6512
|
+
if (isBlockedStepRun(store.getWorkflowStepRun(run.id, blockedBy))) {
|
|
6513
|
+
store.skipWorkflowStepRun(run.id, step.id, `${BLOCKED_STEP_ERROR_PREFIX} upstream step ${blockedBy} was blocked`, {
|
|
6514
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
6515
|
+
});
|
|
6516
|
+
continue;
|
|
6517
|
+
}
|
|
6518
|
+
store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
|
|
6519
|
+
blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
|
|
6520
|
+
terminalStatus = "failed";
|
|
6382
6521
|
continue;
|
|
6383
6522
|
}
|
|
6384
|
-
|
|
6385
|
-
|
|
6386
|
-
|
|
6387
|
-
|
|
6388
|
-
|
|
6389
|
-
|
|
6390
|
-
|
|
6391
|
-
|
|
6392
|
-
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
|
|
6396
|
-
|
|
6397
|
-
|
|
6398
|
-
|
|
6399
|
-
|
|
6400
|
-
|
|
6401
|
-
|
|
6402
|
-
|
|
6403
|
-
});
|
|
6404
|
-
let result;
|
|
6405
|
-
const controller = new AbortController;
|
|
6406
|
-
const externalAbort = () => controller.abort();
|
|
6407
|
-
if (opts.signal?.aborted)
|
|
6408
|
-
controller.abort();
|
|
6409
|
-
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
6410
|
-
const cancelTimer = setInterval(() => {
|
|
6411
|
-
if (store.getWorkflowRun(run.id)?.status === "cancelled")
|
|
6523
|
+
opts.beforePersist?.();
|
|
6524
|
+
const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
|
|
6525
|
+
if (startedStep.status !== "running") {
|
|
6526
|
+
terminalStatus = "failed";
|
|
6527
|
+
blockingError = `step ${step.id} could not start because workflow is no longer running`;
|
|
6528
|
+
break;
|
|
6529
|
+
}
|
|
6530
|
+
const stepContext = goalExecutionContext({
|
|
6531
|
+
loop: opts.loop,
|
|
6532
|
+
loopRun: opts.loopRun,
|
|
6533
|
+
scheduledFor: opts.scheduledFor,
|
|
6534
|
+
workflow,
|
|
6535
|
+
workflowRunId: run.id,
|
|
6536
|
+
workflowStepId: step.id
|
|
6537
|
+
});
|
|
6538
|
+
let result;
|
|
6539
|
+
const controller = new AbortController;
|
|
6540
|
+
const externalAbort = () => controller.abort();
|
|
6541
|
+
if (opts.signal?.aborted)
|
|
6412
6542
|
controller.abort();
|
|
6413
|
-
|
|
6414
|
-
|
|
6415
|
-
|
|
6416
|
-
|
|
6417
|
-
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
|
|
6421
|
-
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
opts
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
|
|
6543
|
+
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
6544
|
+
const cancelTimer = setInterval(() => {
|
|
6545
|
+
if (store.getWorkflowRun(run.id)?.status === "cancelled")
|
|
6546
|
+
controller.abort();
|
|
6547
|
+
}, opts.cancelPollMs ?? 500);
|
|
6548
|
+
cancelTimer.unref();
|
|
6549
|
+
try {
|
|
6550
|
+
if (step.goal) {
|
|
6551
|
+
result = await runGoal(store, step.goal, {
|
|
6552
|
+
...opts,
|
|
6553
|
+
model: opts.goalModel,
|
|
6554
|
+
target: targetWithStepAccount(step),
|
|
6555
|
+
signal: controller.signal,
|
|
6556
|
+
context: stepContext
|
|
6557
|
+
});
|
|
6558
|
+
} else {
|
|
6559
|
+
result = await executeTarget(targetWithStepAccount(step, opts.goalNodePrompt), executionMetadata(stepContext), {
|
|
6560
|
+
...opts,
|
|
6561
|
+
machine: opts.machine ?? opts.loop?.machine,
|
|
6562
|
+
signal: controller.signal,
|
|
6563
|
+
onAgentProgress: (progress) => {
|
|
6564
|
+
const stdout = JSON.stringify({ agentProgress: progress }, null, 2);
|
|
6565
|
+
opts.beforePersist?.();
|
|
6566
|
+
store.recordWorkflowStepProgress(run.id, step.id, {
|
|
6567
|
+
stdout,
|
|
6568
|
+
payload: progress
|
|
6569
|
+
}, {
|
|
6570
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
6571
|
+
});
|
|
6572
|
+
opts.onAgentProgress?.(progress);
|
|
6573
|
+
},
|
|
6574
|
+
onSpawn: (pid) => {
|
|
6575
|
+
opts.beforePersist?.();
|
|
6576
|
+
store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
|
|
6577
|
+
opts.onSpawn?.(pid);
|
|
6578
|
+
}
|
|
6579
|
+
});
|
|
6580
|
+
}
|
|
6581
|
+
} catch (error) {
|
|
6582
|
+
const finishedAt2 = nowIso();
|
|
6583
|
+
result = {
|
|
6584
|
+
status: "failed",
|
|
6585
|
+
stdout: "",
|
|
6586
|
+
stderr: "",
|
|
6587
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6588
|
+
startedAt: startedStep.startedAt ?? finishedAt2,
|
|
6589
|
+
finishedAt: finishedAt2,
|
|
6590
|
+
durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
|
|
6591
|
+
};
|
|
6592
|
+
} finally {
|
|
6593
|
+
clearInterval(cancelTimer);
|
|
6594
|
+
opts.signal?.removeEventListener("abort", externalAbort);
|
|
6595
|
+
}
|
|
6596
|
+
const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
6597
|
+
if (timeoutMessage && result.status === "failed") {
|
|
6598
|
+
result = { ...result, status: "timed_out", error: timeoutMessage };
|
|
6599
|
+
}
|
|
6600
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
6601
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
6602
|
+
blockingError = "workflow run was cancelled";
|
|
6603
|
+
break;
|
|
6604
|
+
}
|
|
6605
|
+
opts.beforePersist?.();
|
|
6606
|
+
const blockedExit = result.status === "failed" && result.exitCode !== undefined && blockedExitCodesForStep(step).includes(result.exitCode);
|
|
6607
|
+
if (blockedExit) {
|
|
6608
|
+
store.finalizeWorkflowStepRun(run.id, step.id, {
|
|
6609
|
+
status: "skipped",
|
|
6610
|
+
finishedAt: result.finishedAt,
|
|
6611
|
+
durationMs: result.durationMs,
|
|
6612
|
+
stdout: result.stdout,
|
|
6613
|
+
stderr: result.stderr,
|
|
6614
|
+
exitCode: result.exitCode,
|
|
6615
|
+
error: `${BLOCKED_STEP_ERROR_PREFIX} step ${step.id} exited with blocked exit code ${result.exitCode}`
|
|
6616
|
+
}, {
|
|
6617
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
6434
6618
|
});
|
|
6619
|
+
continue;
|
|
6435
6620
|
}
|
|
6436
|
-
} catch (error) {
|
|
6437
|
-
const finishedAt2 = nowIso();
|
|
6438
|
-
result = {
|
|
6439
|
-
status: "failed",
|
|
6440
|
-
stdout: "",
|
|
6441
|
-
stderr: "",
|
|
6442
|
-
error: error instanceof Error ? error.message : String(error),
|
|
6443
|
-
startedAt: startedStep.startedAt ?? finishedAt2,
|
|
6444
|
-
finishedAt: finishedAt2,
|
|
6445
|
-
durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
|
|
6446
|
-
};
|
|
6447
|
-
} finally {
|
|
6448
|
-
clearInterval(cancelTimer);
|
|
6449
|
-
opts.signal?.removeEventListener("abort", externalAbort);
|
|
6450
|
-
}
|
|
6451
|
-
const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
6452
|
-
if (timeoutMessage && result.status === "failed") {
|
|
6453
|
-
result = { ...result, status: "timed_out", error: timeoutMessage };
|
|
6454
|
-
}
|
|
6455
|
-
if (store.isWorkflowRunTerminal(run.id)) {
|
|
6456
|
-
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
6457
|
-
blockingError = "workflow run was cancelled";
|
|
6458
|
-
break;
|
|
6459
|
-
}
|
|
6460
|
-
opts.beforePersist?.();
|
|
6461
|
-
const blockedExit = result.status === "failed" && result.exitCode !== undefined && blockedExitCodesForStep(step).includes(result.exitCode);
|
|
6462
|
-
if (blockedExit) {
|
|
6463
6621
|
store.finalizeWorkflowStepRun(run.id, step.id, {
|
|
6464
|
-
status:
|
|
6622
|
+
status: result.status,
|
|
6465
6623
|
finishedAt: result.finishedAt,
|
|
6466
6624
|
durationMs: result.durationMs,
|
|
6467
6625
|
stdout: result.stdout,
|
|
6468
6626
|
stderr: result.stderr,
|
|
6469
6627
|
exitCode: result.exitCode,
|
|
6470
|
-
error:
|
|
6628
|
+
error: result.error
|
|
6471
6629
|
}, {
|
|
6472
6630
|
daemonLeaseId: opts.daemonLeaseId
|
|
6473
6631
|
});
|
|
6474
|
-
|
|
6632
|
+
if (result.status !== "succeeded" && !step.continueOnFailure) {
|
|
6633
|
+
terminalStatus = result.status;
|
|
6634
|
+
blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
|
|
6635
|
+
break;
|
|
6636
|
+
}
|
|
6475
6637
|
}
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6638
|
+
if (terminalStatus !== "succeeded") {
|
|
6639
|
+
for (const step of ordered) {
|
|
6640
|
+
const existing = store.getWorkflowStepRun(run.id, step.id);
|
|
6641
|
+
if (existing?.status === "pending" || existing?.status === "running") {
|
|
6642
|
+
store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
|
|
6643
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
6644
|
+
});
|
|
6645
|
+
}
|
|
6646
|
+
}
|
|
6647
|
+
}
|
|
6648
|
+
const finishedAt = nowIso();
|
|
6649
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
6650
|
+
const terminalRun = store.requireWorkflowRun(run.id);
|
|
6651
|
+
return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, store.listWorkflowStepRuns(run.id), terminalRun.error ?? blockingError);
|
|
6652
|
+
}
|
|
6653
|
+
opts.beforePersist?.();
|
|
6654
|
+
const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
|
|
6655
|
+
finishedAt,
|
|
6656
|
+
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
6657
|
+
error: blockingError
|
|
6484
6658
|
}, {
|
|
6485
6659
|
daemonLeaseId: opts.daemonLeaseId
|
|
6486
6660
|
});
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
if (
|
|
6497
|
-
store.
|
|
6661
|
+
return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), blockingError);
|
|
6662
|
+
} catch (error) {
|
|
6663
|
+
if (opts.daemonLeaseId && error instanceof Error && error.message === "daemon lease lost")
|
|
6664
|
+
throw error;
|
|
6665
|
+
if (error instanceof Error && error.message.includes("workflow step is not claimable"))
|
|
6666
|
+
throw error;
|
|
6667
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6668
|
+
const finishedAt = nowIso();
|
|
6669
|
+
try {
|
|
6670
|
+
if (!store.isWorkflowRunTerminal(run.id)) {
|
|
6671
|
+
store.finalizeWorkflowRun(run.id, "failed", {
|
|
6672
|
+
finishedAt,
|
|
6673
|
+
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
6674
|
+
error: message
|
|
6675
|
+
}, {
|
|
6498
6676
|
daemonLeaseId: opts.daemonLeaseId
|
|
6499
6677
|
});
|
|
6500
6678
|
}
|
|
6501
|
-
}
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
const terminalRun = store.requireWorkflowRun(run.id);
|
|
6506
|
-
return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, store.listWorkflowStepRuns(run.id), terminalRun.error ?? blockingError);
|
|
6679
|
+
} catch {}
|
|
6680
|
+
const current = store.getWorkflowRun(run.id) ?? run;
|
|
6681
|
+
const resultStatus = current.status === "running" ? "failed" : current.status;
|
|
6682
|
+
return workflowResult(current, resultStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), current.error ?? message);
|
|
6507
6683
|
}
|
|
6508
|
-
opts.beforePersist?.();
|
|
6509
|
-
const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
|
|
6510
|
-
finishedAt,
|
|
6511
|
-
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
6512
|
-
error: blockingError
|
|
6513
|
-
}, {
|
|
6514
|
-
daemonLeaseId: opts.daemonLeaseId
|
|
6515
|
-
});
|
|
6516
|
-
return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), blockingError);
|
|
6517
6684
|
}
|
|
6518
6685
|
function preflightWorkflow(workflow, opts = {}) {
|
|
6519
6686
|
return workflowExecutionOrder(workflow).map((step) => {
|
|
@@ -6935,7 +7102,7 @@ ${failure.evidence.stderr}` : undefined
|
|
|
6935
7102
|
|
|
6936
7103
|
`);
|
|
6937
7104
|
const dedupeKey = `openloops:${loop.id}:${failure.fingerprint}`;
|
|
6938
|
-
const tags = ["bug", "openloops", "loop-health", failure.classification];
|
|
7105
|
+
const tags = ["bug", "openloops", "loops", "loop-health", failure.classification];
|
|
6939
7106
|
const priority = failure.classification === "auth" || failure.classification === "rate_limit" ? "high" : "medium";
|
|
6940
7107
|
return {
|
|
6941
7108
|
title,
|
|
@@ -6950,7 +7117,7 @@ ${failure.evidence.stderr}` : undefined
|
|
|
6950
7117
|
comment: ["todos", "comment", "<task-id>", description]
|
|
6951
7118
|
},
|
|
6952
7119
|
futureNativeUpsert: {
|
|
6953
|
-
command: "todos upsert",
|
|
7120
|
+
command: "todos task upsert",
|
|
6954
7121
|
fields: {
|
|
6955
7122
|
title,
|
|
6956
7123
|
description,
|
|
@@ -7309,6 +7476,25 @@ function advanceOptions(deps) {
|
|
|
7309
7476
|
onRun: deps.onRun
|
|
7310
7477
|
};
|
|
7311
7478
|
}
|
|
7479
|
+
var TERMINAL_RUN_STATUSES2 = new Set([
|
|
7480
|
+
"succeeded",
|
|
7481
|
+
"failed",
|
|
7482
|
+
"timed_out",
|
|
7483
|
+
"abandoned",
|
|
7484
|
+
"skipped"
|
|
7485
|
+
]);
|
|
7486
|
+
function repairWedgedTerminalSlot(deps, loop, scheduledFor, now) {
|
|
7487
|
+
const existing = deps.store.getRunBySlot(loop.id, scheduledFor);
|
|
7488
|
+
if (!existing || !TERMINAL_RUN_STATUSES2.has(existing.status))
|
|
7489
|
+
return;
|
|
7490
|
+
try {
|
|
7491
|
+
advanceLoop(deps.store, loop, existing, new Date(existing.finishedAt ?? now), existing.status === "succeeded", advanceOptions(deps));
|
|
7492
|
+
} catch (error) {
|
|
7493
|
+
if (deps.daemonLeaseId && isDaemonLeaseLost(error))
|
|
7494
|
+
return;
|
|
7495
|
+
throw error;
|
|
7496
|
+
}
|
|
7497
|
+
}
|
|
7312
7498
|
async function runSlot(deps, loop, scheduledFor) {
|
|
7313
7499
|
const now = deps.now?.() ?? new Date;
|
|
7314
7500
|
deps.beforeRun?.(loop, scheduledFor);
|
|
@@ -7335,8 +7521,10 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
7335
7521
|
return;
|
|
7336
7522
|
throw error;
|
|
7337
7523
|
}
|
|
7338
|
-
if (!claim)
|
|
7524
|
+
if (!claim) {
|
|
7525
|
+
repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
|
|
7339
7526
|
return;
|
|
7527
|
+
}
|
|
7340
7528
|
deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
|
|
7341
7529
|
deps.onRun?.(claim.run);
|
|
7342
7530
|
const finalRun = await executeClaimedRun({
|
|
@@ -7382,8 +7570,10 @@ function claimSlot(deps, loop, scheduledFor) {
|
|
|
7382
7570
|
return;
|
|
7383
7571
|
throw error;
|
|
7384
7572
|
}
|
|
7385
|
-
if (!claim)
|
|
7573
|
+
if (!claim) {
|
|
7574
|
+
repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
|
|
7386
7575
|
return;
|
|
7576
|
+
}
|
|
7387
7577
|
deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
|
|
7388
7578
|
deps.onRun?.(claim.run);
|
|
7389
7579
|
return claim;
|
|
@@ -7664,10 +7854,7 @@ async function stopDaemon(opts = {}) {
|
|
|
7664
7854
|
const store = new Store(join5(dirname4(path), "loops.db"));
|
|
7665
7855
|
try {
|
|
7666
7856
|
const lease = store.getDaemonLease();
|
|
7667
|
-
|
|
7668
|
-
removePid(path);
|
|
7669
|
-
return { wasRunning: false, stopped: false, forced: false, pid: state.pid };
|
|
7670
|
-
}
|
|
7857
|
+
const leaseVerified = Boolean(lease && lease.pid === state.pid && new Date(lease.expiresAt).getTime() > Date.now());
|
|
7671
7858
|
try {
|
|
7672
7859
|
process.kill(state.pid, "SIGTERM");
|
|
7673
7860
|
} catch {
|
|
@@ -7687,11 +7874,14 @@ async function stopDaemon(opts = {}) {
|
|
|
7687
7874
|
} catch {}
|
|
7688
7875
|
await sleep2(150);
|
|
7689
7876
|
removePid(path);
|
|
7690
|
-
|
|
7691
|
-
|
|
7692
|
-
|
|
7693
|
-
|
|
7694
|
-
|
|
7877
|
+
let reapedPgids = [];
|
|
7878
|
+
if (leaseVerified && lease) {
|
|
7879
|
+
const ownedRuns = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.claimedBy !== undefined && run.claimedBy.endsWith(`:${lease.id}`));
|
|
7880
|
+
reapedPgids = await reapProcessGroups(ownedRuns.map(toReapableProcess), {
|
|
7881
|
+
sleep: sleep2,
|
|
7882
|
+
graceMs: opts.reapGraceMs
|
|
7883
|
+
});
|
|
7884
|
+
}
|
|
7695
7885
|
return { wasRunning: true, stopped: !isAlive(state.pid, record?.startedAt), forced: true, pid: state.pid, reapedPgids };
|
|
7696
7886
|
} finally {
|
|
7697
7887
|
store.close();
|
|
@@ -7879,7 +8069,14 @@ async function runDaemon(opts = {}) {
|
|
|
7879
8069
|
try {
|
|
7880
8070
|
const liveInlineOwner = (run) => {
|
|
7881
8071
|
const ownerPid = inlineRunnerOwnerPid(run.claimedBy);
|
|
7882
|
-
|
|
8072
|
+
if (ownerPid === undefined || !isAlive(ownerPid))
|
|
8073
|
+
return false;
|
|
8074
|
+
const ownerStartMs = processStartTimeMs(ownerPid);
|
|
8075
|
+
const runStartMs = run.startedAt ? Date.parse(run.startedAt) : Number.NaN;
|
|
8076
|
+
if (ownerStartMs !== undefined && Number.isFinite(runStartMs) && ownerStartMs > runStartMs + START_TIME_TOLERANCE_MS) {
|
|
8077
|
+
return false;
|
|
8078
|
+
}
|
|
8079
|
+
return true;
|
|
7883
8080
|
};
|
|
7884
8081
|
const staleCandidates = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.leaseExpiresAt !== undefined && new Date(run.leaseExpiresAt).getTime() <= Date.now());
|
|
7885
8082
|
const startup = claimDueRuns({ store, runnerId, daemonLeaseId: leaseId, maxClaims: 0 });
|
|
@@ -9164,6 +9361,9 @@ function roleFragment(role, flow) {
|
|
|
9164
9361
|
function goalHeaderFragment(goal, role, flow) {
|
|
9165
9362
|
return [`/goal ${goal}`, "", roleFragment(role, flow)];
|
|
9166
9363
|
}
|
|
9364
|
+
function boundedStepHeaderFragment(objective, role, flow) {
|
|
9365
|
+
return [`Objective: ${objective}`, "", roleFragment(role, flow)];
|
|
9366
|
+
}
|
|
9167
9367
|
function adversarialReviewFragment(inspect, focus) {
|
|
9168
9368
|
return `Use fresh context. Inspect ${inspect}. Act as an adversarial reviewer focused on ${focus}.`;
|
|
9169
9369
|
}
|
|
@@ -9815,6 +10015,28 @@ function importCustomLoopTemplate(file, reservedKeys, opts = {}) {
|
|
|
9815
10015
|
function compactJson(value) {
|
|
9816
10016
|
return JSON.stringify(value);
|
|
9817
10017
|
}
|
|
10018
|
+
function prReviewRoutingContext(input) {
|
|
10019
|
+
return input.prReviewRouting?.required ? input.prReviewRouting : undefined;
|
|
10020
|
+
}
|
|
10021
|
+
function prReviewFollowUpFragment(input) {
|
|
10022
|
+
const routing = prReviewRoutingContext(input);
|
|
10023
|
+
const author = routing?.author?.trim();
|
|
10024
|
+
const reviewers = routing?.reviewers?.map((reviewer) => reviewer.trim()).filter(Boolean) ?? [];
|
|
10025
|
+
const evidenceLines = [
|
|
10026
|
+
author ? `- Source PR author evidence: GitHub author is ${author}` : undefined,
|
|
10027
|
+
reviewers.length ? `- Source PR reviewer evidence: GitHub reviewer pool: ${reviewers.join(", ")}` : undefined,
|
|
10028
|
+
routing?.selectedReviewer ? `- Selected non-author reviewer: ${routing.selectedReviewer}` : undefined
|
|
10029
|
+
].filter(Boolean);
|
|
10030
|
+
return [
|
|
10031
|
+
"PR-derived follow-up todos: If any lifecycle step creates a follow-up todo that references a GitHub PR, PR approval, PR review, or PR merge work, the todo description must include parser-compatible routing evidence so downstream drains can select a non-author reviewer.",
|
|
10032
|
+
...evidenceLines,
|
|
10033
|
+
"Copy these exact evidence lines from the source task when present, or derive them from the referenced PR before creating the follow-up todo:",
|
|
10034
|
+
"GitHub author is <login>",
|
|
10035
|
+
"GitHub reviewer pool: <login>, <login>",
|
|
10036
|
+
"When the source PR author or reviewer pool cannot be determined, do not create an auto-routable PR-derived follow-up todo; comment the source task with the blocker instead."
|
|
10037
|
+
].join(`
|
|
10038
|
+
`);
|
|
10039
|
+
}
|
|
9818
10040
|
function taskLabel(input) {
|
|
9819
10041
|
const head = input.taskTitle?.trim() || input.taskId;
|
|
9820
10042
|
return head.length > 160 ? `${head.slice(0, 157)}...` : head;
|
|
@@ -10270,6 +10492,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10270
10492
|
routeProjectPath: input.routeProjectPath,
|
|
10271
10493
|
projectGroup: input.projectGroup,
|
|
10272
10494
|
todosProjectPath,
|
|
10495
|
+
prReviewRouting: prReviewRoutingContext(input),
|
|
10273
10496
|
worktree: worktreeContextFragment(plan)
|
|
10274
10497
|
};
|
|
10275
10498
|
const handoffArtifactPath = prHandoffArtifactPath(plan, input.taskId);
|
|
@@ -10287,6 +10510,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10287
10510
|
]),
|
|
10288
10511
|
NO_TMUX_DISPATCH_FRAGMENT,
|
|
10289
10512
|
"Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
|
|
10513
|
+
prReviewFollowUpFragment(input),
|
|
10290
10514
|
"",
|
|
10291
10515
|
`Task context JSON: ${compactJson(taskContext)}`,
|
|
10292
10516
|
prHandoffGuidance
|
|
@@ -10296,7 +10520,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10296
10520
|
const blockTaskCommand = `todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
|
|
10297
10521
|
const gateStopFragment = (stage, stops) => `The deterministic ${stage} gate will stop ${stops} unless the latest ${stage} marker is the exact go marker and the task has no blocked/completed/done/cancelled/failed/archived/no-auto/manual/approval-required state.`;
|
|
10298
10522
|
const triagePrompt = [
|
|
10299
|
-
...
|
|
10523
|
+
...boundedStepHeaderFragment(`Triage todos task ${input.taskId} for safe automated execution.`, "triage", "lifecycle"),
|
|
10300
10524
|
shared,
|
|
10301
10525
|
"Decide whether the task is eligible for loop execution. Check status, dependencies, duplicate tasks, no-auto/manual/approval metadata, project path, acceptance criteria, and whether the requested work should be split before implementation.",
|
|
10302
10526
|
"Do not implement repo changes in this step.",
|
|
@@ -10308,7 +10532,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10308
10532
|
].join(`
|
|
10309
10533
|
`);
|
|
10310
10534
|
const plannerPrompt = [
|
|
10311
|
-
...
|
|
10535
|
+
...boundedStepHeaderFragment(`Plan todos task ${input.taskId} before implementation.`, "planner", "lifecycle"),
|
|
10312
10536
|
shared,
|
|
10313
10537
|
"Read the triage comment and current task details.",
|
|
10314
10538
|
`If the task is ready for implementation, add a task comment whose first line is exactly: ${gateMarker("planner", "go")}`,
|
|
@@ -10319,7 +10543,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10319
10543
|
].join(`
|
|
10320
10544
|
`);
|
|
10321
10545
|
const workerPrompt = [
|
|
10322
|
-
...
|
|
10546
|
+
...boundedStepHeaderFragment(`Complete todos task ${input.taskId} according to the planner evidence.`, "worker", "lifecycle"),
|
|
10323
10547
|
shared,
|
|
10324
10548
|
todosStartLine(todosProjectPath, input.taskId),
|
|
10325
10549
|
"Read the triage and planner comments first. Implement only the scoped task, run focused validation, and record changed files, commits, evidence, blockers, and residual risks.",
|
|
@@ -10328,7 +10552,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10328
10552
|
].filter(Boolean).join(`
|
|
10329
10553
|
`);
|
|
10330
10554
|
const verifierPrompt = [
|
|
10331
|
-
...
|
|
10555
|
+
...boundedStepHeaderFragment(`Verify todos task ${input.taskId} after the full lifecycle worker step.`, "verifier", "lifecycle"),
|
|
10332
10556
|
shared,
|
|
10333
10557
|
todosVerificationLine(todosProjectPath, input.taskId),
|
|
10334
10558
|
todosDoneLine(todosProjectPath, input.taskId),
|
|
@@ -11034,7 +11258,7 @@ function taskRouteEligibility(data, metadata) {
|
|
|
11034
11258
|
const records = taskEventRecords(data, metadata);
|
|
11035
11259
|
const automation = automationRecords(data, metadata);
|
|
11036
11260
|
const tags = taskEventTags(records);
|
|
11037
|
-
const hasRouteOptIn = tags.includes("auto:route") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed"]) || hasTruthyField(automation, ["allowed"]);
|
|
11261
|
+
const hasRouteOptIn = tags.includes("auto:route") || tags.includes("route:enabled") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed"]) || hasTruthyField(automation, ["allowed"]);
|
|
11038
11262
|
if (!hasRouteOptIn)
|
|
11039
11263
|
return { eligible: false, reason: "missing explicit route opt-in", tags };
|
|
11040
11264
|
const status = taskEventField(data, ["status", "task_status", "taskStatus"])?.toLowerCase();
|
|
@@ -11472,12 +11696,30 @@ function routeEvidenceText(records) {
|
|
|
11472
11696
|
const values = [];
|
|
11473
11697
|
for (const record of records) {
|
|
11474
11698
|
for (const field of fields)
|
|
11475
|
-
values.push(...
|
|
11699
|
+
values.push(...routeTextFieldValues(record, field));
|
|
11476
11700
|
values.push(...tagsFromValue2(record.tags ?? record.task_tags ?? record.taskTags));
|
|
11477
11701
|
}
|
|
11478
11702
|
return values.join(`
|
|
11479
11703
|
`);
|
|
11480
11704
|
}
|
|
11705
|
+
function textValuesFromUnknown(value) {
|
|
11706
|
+
if (Array.isArray(value))
|
|
11707
|
+
return value.flatMap((entry) => textValuesFromUnknown(entry));
|
|
11708
|
+
if (typeof value === "string" && value.trim())
|
|
11709
|
+
return [value.trim()];
|
|
11710
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
11711
|
+
return [String(value)];
|
|
11712
|
+
return [];
|
|
11713
|
+
}
|
|
11714
|
+
function routeTextFieldValues(record, field) {
|
|
11715
|
+
const expected = canonicalRouteField(field);
|
|
11716
|
+
const values = [];
|
|
11717
|
+
for (const [key, value] of Object.entries(record)) {
|
|
11718
|
+
if (canonicalRouteField(key) === expected)
|
|
11719
|
+
values.push(...textValuesFromUnknown(value));
|
|
11720
|
+
}
|
|
11721
|
+
return values;
|
|
11722
|
+
}
|
|
11481
11723
|
function authorFromPrText(text) {
|
|
11482
11724
|
const patterns = [
|
|
11483
11725
|
/\bauthor\s+(?:is\s+also|is|=|:)\s+@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/i,
|
|
@@ -11492,6 +11734,18 @@ function authorFromPrText(text) {
|
|
|
11492
11734
|
}
|
|
11493
11735
|
return;
|
|
11494
11736
|
}
|
|
11737
|
+
function reviewersFromPrText(text) {
|
|
11738
|
+
const reviewers = [];
|
|
11739
|
+
const patterns = [
|
|
11740
|
+
/\bgithub\s+reviewer\s+pool\s*(?:is|=|:)\s*([^\r\n]+)/gi,
|
|
11741
|
+
/\bgithub\s+reviewers?\s*(?:are|is|=|:)\s*([^\r\n]+)/gi
|
|
11742
|
+
];
|
|
11743
|
+
for (const pattern of patterns) {
|
|
11744
|
+
for (const match of text.matchAll(pattern))
|
|
11745
|
+
reviewers.push(...splitList(match[1]) ?? []);
|
|
11746
|
+
}
|
|
11747
|
+
return githubLogins(reviewers);
|
|
11748
|
+
}
|
|
11495
11749
|
function prReviewRoutingDecision(data, metadata, opts) {
|
|
11496
11750
|
const records = [...taskEventRecords(data, metadata), ...automationRecords(data, metadata)];
|
|
11497
11751
|
const text = routeEvidenceText(records);
|
|
@@ -11517,7 +11771,8 @@ function prReviewRoutingDecision(data, metadata, opts) {
|
|
|
11517
11771
|
opts.githubReviewer,
|
|
11518
11772
|
...splitList(opts.githubReviewerPool) ?? [],
|
|
11519
11773
|
...PR_REVIEWER_FIELDS.flatMap((field) => routeFieldValues(records, field)),
|
|
11520
|
-
...PR_REVIEWER_POOL_FIELDS.flatMap((field) => routeFieldValues(records, field))
|
|
11774
|
+
...PR_REVIEWER_POOL_FIELDS.flatMap((field) => routeFieldValues(records, field)),
|
|
11775
|
+
...reviewersFromPrText(text)
|
|
11521
11776
|
]);
|
|
11522
11777
|
const selectedReviewer = author ? reviewers.find((reviewer) => reviewer.toLowerCase() !== author.toLowerCase()) : undefined;
|
|
11523
11778
|
if (!author) {
|
|
@@ -11905,7 +12160,16 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11905
12160
|
}
|
|
11906
12161
|
const taskTitle = taskEventField(data, ["title", "task_title", "taskTitle"]);
|
|
11907
12162
|
const taskDescription = taskEventField(data, ["description", "body"]);
|
|
11908
|
-
const
|
|
12163
|
+
const sourceTodosProjectPath = opts.sourceTodosProjectPath?.trim();
|
|
12164
|
+
const dataProjectPath = taskEventField(data, [
|
|
12165
|
+
"route_project_path",
|
|
12166
|
+
"routeProjectPath",
|
|
12167
|
+
"project_path",
|
|
12168
|
+
"projectPath",
|
|
12169
|
+
"working_dir",
|
|
12170
|
+
"workingDir",
|
|
12171
|
+
"cwd"
|
|
12172
|
+
]);
|
|
11909
12173
|
const metadataProjectPath = taskEventField(metadata, [
|
|
11910
12174
|
"working_dir",
|
|
11911
12175
|
"workingDir",
|
|
@@ -11918,7 +12182,8 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11918
12182
|
const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve7(projectPath);
|
|
11919
12183
|
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
11920
12184
|
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
11921
|
-
const
|
|
12185
|
+
const sourceProjectIdempotencyPrefix = sourceTodosProjectPath ? normalizeRoutePath(sourceTodosProjectPath) ?? resolve7(sourceTodosProjectPath) : undefined;
|
|
12186
|
+
const idempotencyKey = sourceProjectIdempotencyPrefix ? `todos-task:${sourceProjectIdempotencyPrefix}:${taskId}` : `todos-task:${taskId}`;
|
|
11922
12187
|
const idempotencySuffix = stableSuffix(idempotencyKey);
|
|
11923
12188
|
const namePrefix = opts.namePrefix ?? "event:todos-task";
|
|
11924
12189
|
const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
|
|
@@ -11992,9 +12257,10 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11992
12257
|
worktreeRoot: opts.worktreeRoot,
|
|
11993
12258
|
worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops",
|
|
11994
12259
|
prHandoff: templateId === TASK_LIFECYCLE_TEMPLATE_ID ? Boolean(opts.prHandoff) : false,
|
|
12260
|
+
prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
|
|
11995
12261
|
eventId: event.id,
|
|
11996
12262
|
eventType: event.type,
|
|
11997
|
-
todosProjectPath: opts.todosProject
|
|
12263
|
+
todosProjectPath: sourceTodosProjectPath || opts.todosProject
|
|
11998
12264
|
};
|
|
11999
12265
|
const workflowContext = {
|
|
12000
12266
|
name: workflowName,
|
|
@@ -12262,8 +12528,8 @@ function taskField(task, keys) {
|
|
|
12262
12528
|
}
|
|
12263
12529
|
return;
|
|
12264
12530
|
}
|
|
12265
|
-
function
|
|
12266
|
-
return
|
|
12531
|
+
function taskRoutePathFromRegistry(sourceProjectPath) {
|
|
12532
|
+
return normalizeRoutePath(sourceProjectPath) ?? resolve8(sourceProjectPath);
|
|
12267
12533
|
}
|
|
12268
12534
|
function taskProjectId(task) {
|
|
12269
12535
|
return taskField(task, ["project_id", "projectId"]);
|
|
@@ -12277,12 +12543,28 @@ function taskProjectPath(task) {
|
|
|
12277
12543
|
const metadata = objectField2(task.metadata) ?? {};
|
|
12278
12544
|
return taskField(task, ["project_path", "projectPath"]) ?? taskEventField(metadata, ["project_path", "projectPath", "project_canonical_path"]) ?? taskDescriptionProjectPath(task) ?? taskField(task, ["working_dir", "workingDir", "cwd"]) ?? taskEventField(metadata, ["working_dir", "workingDir", "cwd"]);
|
|
12279
12545
|
}
|
|
12280
|
-
function
|
|
12546
|
+
function taskListValues(task) {
|
|
12547
|
+
const taskList = objectField2(task.task_list);
|
|
12548
|
+
return [
|
|
12549
|
+
taskField(task, ["task_list_id", "taskListId"]),
|
|
12550
|
+
stringField2(task.task_list?.id),
|
|
12551
|
+
stringField2(task.task_list?.slug),
|
|
12552
|
+
stringField2(taskList?.name),
|
|
12553
|
+
taskField(task, ["task_list", "taskList"])
|
|
12554
|
+
].filter((value) => Boolean(value));
|
|
12555
|
+
}
|
|
12556
|
+
function taskDrainEvent(task, sourceProjectPath) {
|
|
12281
12557
|
const taskId = taskField(task, ["id", "task_id", "taskId"]);
|
|
12282
12558
|
if (!taskId)
|
|
12283
12559
|
throw new Error("todos ready returned a task without an id");
|
|
12284
|
-
const metadata = objectField2(task.metadata) ?? {};
|
|
12560
|
+
const metadata = { ...objectField2(task.metadata) ?? {} };
|
|
12285
12561
|
const workingDir = taskProjectPath(task);
|
|
12562
|
+
const routeWorkingDir = taskField(task, ["project_path", "projectPath"]) ?? workingDir;
|
|
12563
|
+
const sourceProject = sourceProjectPath?.trim();
|
|
12564
|
+
if (!sourceProject) {
|
|
12565
|
+
delete metadata.source_project_path;
|
|
12566
|
+
delete metadata.sourceProjectPath;
|
|
12567
|
+
}
|
|
12286
12568
|
const data = {
|
|
12287
12569
|
...task,
|
|
12288
12570
|
id: taskId,
|
|
@@ -12292,10 +12574,23 @@ function taskDrainEvent(task) {
|
|
|
12292
12574
|
tags: tagsFromValue2(task.tags),
|
|
12293
12575
|
metadata
|
|
12294
12576
|
};
|
|
12577
|
+
if (!sourceProject) {
|
|
12578
|
+
delete data.source_project_path;
|
|
12579
|
+
delete data.sourceProjectPath;
|
|
12580
|
+
}
|
|
12295
12581
|
if (workingDir) {
|
|
12296
|
-
data.working_dir =
|
|
12297
|
-
data.project_path =
|
|
12298
|
-
data.cwd = taskField(task, ["cwd"]) ??
|
|
12582
|
+
data.working_dir = routeWorkingDir;
|
|
12583
|
+
data.project_path = routeWorkingDir;
|
|
12584
|
+
data.cwd = taskField(task, ["cwd"]) ?? routeWorkingDir;
|
|
12585
|
+
}
|
|
12586
|
+
if (sourceProject) {
|
|
12587
|
+
data.source_project_path = sourceProject;
|
|
12588
|
+
if (!data.project_path)
|
|
12589
|
+
data.project_path = sourceProject;
|
|
12590
|
+
data.route_project_path = data.project_path;
|
|
12591
|
+
data.routeProjectPath = data.project_path;
|
|
12592
|
+
metadata.route_project_path = data.project_path;
|
|
12593
|
+
metadata.routeProjectPath = data.project_path;
|
|
12299
12594
|
}
|
|
12300
12595
|
const time = new Date().toISOString();
|
|
12301
12596
|
return {
|
|
@@ -12309,6 +12604,7 @@ function taskDrainEvent(task) {
|
|
|
12309
12604
|
schemaVersion: "1.0",
|
|
12310
12605
|
metadata: {
|
|
12311
12606
|
...metadata,
|
|
12607
|
+
...sourceProject ? { source_project_path: sourceProject } : {},
|
|
12312
12608
|
...workingDir ? { working_dir: workingDir, project_path: data.project_path, cwd: data.cwd } : {},
|
|
12313
12609
|
drained_by: "@hasna/loops",
|
|
12314
12610
|
drained_from: "todos ready"
|
|
@@ -12335,26 +12631,78 @@ function compactDrainResult(result) {
|
|
|
12335
12631
|
workflowName: stringField2(workflow?.name),
|
|
12336
12632
|
providerRouting,
|
|
12337
12633
|
requeue,
|
|
12338
|
-
queuedAtSource: value.queuedAtSource
|
|
12634
|
+
queuedAtSource: value.queuedAtSource,
|
|
12635
|
+
fatal: value.fatal === true ? true : undefined
|
|
12339
12636
|
};
|
|
12340
12637
|
}
|
|
12341
|
-
function
|
|
12342
|
-
|
|
12343
|
-
|
|
12344
|
-
|
|
12345
|
-
|
|
12346
|
-
throw new Error(
|
|
12347
|
-
|
|
12638
|
+
function parseTodosReadyJson(stdout) {
|
|
12639
|
+
try {
|
|
12640
|
+
return JSON.parse(stdout || "[]");
|
|
12641
|
+
} catch (error) {
|
|
12642
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
12643
|
+
throw new Error(`failed to parse todos ready --json output (${stdout.length} bytes): ${message}`);
|
|
12644
|
+
}
|
|
12645
|
+
}
|
|
12646
|
+
function parseTodoProjectsJson(stdout) {
|
|
12348
12647
|
try {
|
|
12349
|
-
|
|
12648
|
+
return JSON.parse(stdout || "[]");
|
|
12350
12649
|
} catch (error) {
|
|
12351
12650
|
const message = error instanceof Error ? error.message : String(error);
|
|
12352
|
-
throw new Error(`failed to parse todos
|
|
12651
|
+
throw new Error(`failed to parse todos projects --json output (${stdout.length} bytes): ${message}`);
|
|
12353
12652
|
}
|
|
12653
|
+
}
|
|
12654
|
+
function loadTodoProjectPathsFromRegistry(opts) {
|
|
12655
|
+
const result = runLocalCommand("todos", ["projects", "--json"], { timeoutMs: 60000 });
|
|
12656
|
+
if (!result.ok)
|
|
12657
|
+
throw new Error(result.stderr || result.error || "todos projects failed");
|
|
12658
|
+
const payload = parseTodoProjectsJson(result.stdout);
|
|
12659
|
+
const projectsPayload = Array.isArray(payload) ? payload : objectField2(payload)?.projects;
|
|
12660
|
+
if (!Array.isArray(projectsPayload))
|
|
12661
|
+
throw new Error("todos projects --json returned a non-array value");
|
|
12662
|
+
const includeFilters = listFromRepeatedOpts(opts.todosProjectInclude)?.map((entry) => normalizeRoutePath(entry) ?? resolve8(entry)) ?? [];
|
|
12663
|
+
const includesPrefix = normalizeRoutePath(opts.projectPathPrefix) ?? (opts.projectPathPrefix ? resolve8(opts.projectPathPrefix) : undefined);
|
|
12664
|
+
const fromProjects = projectsPayload.map((entry) => objectField2(entry)).filter((project) => Boolean(project)).map((project) => {
|
|
12665
|
+
const path = stringField2(project.path) ?? stringField2(project.projectPath) ?? stringField2(project.project_path) ?? stringField2(project.dir) ?? stringField2(project.root) ?? stringField2(project.cwd);
|
|
12666
|
+
if (!path)
|
|
12667
|
+
return;
|
|
12668
|
+
return { path };
|
|
12669
|
+
}).filter((project) => Boolean(project));
|
|
12670
|
+
const paths = fromProjects.map((project) => project.path).filter(Boolean).filter((path) => {
|
|
12671
|
+
const normalizedPath = normalizeRoutePath(path) ?? resolve8(path);
|
|
12672
|
+
if (includesPrefix && !(normalizedPath === includesPrefix || normalizedPath.startsWith(`${includesPrefix}/`)))
|
|
12673
|
+
return false;
|
|
12674
|
+
if (includeFilters.length === 0)
|
|
12675
|
+
return true;
|
|
12676
|
+
return includeFilters.some((prefix) => normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`));
|
|
12677
|
+
});
|
|
12678
|
+
return [...new Set(paths)];
|
|
12679
|
+
}
|
|
12680
|
+
function loadReadyTodosTasksForProject(projectPath, scanLimit) {
|
|
12681
|
+
const args = ["--project", projectPath, "--json", "ready", "--limit", String(scanLimit)];
|
|
12682
|
+
const result = runLocalCommandWithStdoutFile("todos", args, { timeoutMs: 60000, maxBuffer: 64 * 1024 * 1024 });
|
|
12683
|
+
if (!result.ok)
|
|
12684
|
+
throw new Error(result.stderr || result.error || "todos ready failed");
|
|
12685
|
+
const parsed = parseTodosReadyJson(result.stdout);
|
|
12354
12686
|
if (!Array.isArray(parsed))
|
|
12355
12687
|
throw new Error("todos ready --json returned a non-array value");
|
|
12356
12688
|
return parsed;
|
|
12357
12689
|
}
|
|
12690
|
+
function loadReadyTodosTasks(opts, scanLimit) {
|
|
12691
|
+
if (!opts.todosProjectsFromRegistry) {
|
|
12692
|
+
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
12693
|
+
return loadReadyTodosTasksForProject(todosProject, scanLimit);
|
|
12694
|
+
}
|
|
12695
|
+
const projectPaths = loadTodoProjectPathsFromRegistry(opts);
|
|
12696
|
+
const ready = projectPaths.flatMap((projectPath) => loadReadyTodosTasksForProject(projectPath, scanLimit).map((task) => {
|
|
12697
|
+
const sourceProject = projectPath;
|
|
12698
|
+
return {
|
|
12699
|
+
...task,
|
|
12700
|
+
source_project_path: sourceProject,
|
|
12701
|
+
project_path: taskRoutePathFromRegistry(sourceProject)
|
|
12702
|
+
};
|
|
12703
|
+
}));
|
|
12704
|
+
return ready;
|
|
12705
|
+
}
|
|
12358
12706
|
function resolveTaskListFilter(todosProject, filter) {
|
|
12359
12707
|
const wanted = filter?.trim();
|
|
12360
12708
|
if (!wanted)
|
|
@@ -12369,7 +12717,7 @@ function resolveTaskListFilter(todosProject, filter) {
|
|
|
12369
12717
|
function taskMatchesDrainFilters(task, filters) {
|
|
12370
12718
|
if (filters.projectId && taskProjectId(task) !== filters.projectId)
|
|
12371
12719
|
return false;
|
|
12372
|
-
if (filters.
|
|
12720
|
+
if (filters.taskList && !taskListValues(task).includes(filters.taskList))
|
|
12373
12721
|
return false;
|
|
12374
12722
|
if (filters.projectPathPrefix) {
|
|
12375
12723
|
const path = taskProjectPath(task);
|
|
@@ -12407,14 +12755,14 @@ function skippedDrainTask(task, event, reason, extra = {}) {
|
|
|
12407
12755
|
function isSkippableDrainRouteError(message) {
|
|
12408
12756
|
return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
|
|
12409
12757
|
}
|
|
12410
|
-
function markInvalidDrainTaskNonRouteable(
|
|
12758
|
+
function markInvalidDrainTaskNonRouteable(sourceTodosProject, task, reason) {
|
|
12411
12759
|
const taskId = taskField(task, ["id", "task_id", "taskId"]);
|
|
12412
12760
|
if (!taskId)
|
|
12413
12761
|
return { attempted: false, reason: "task id missing" };
|
|
12414
12762
|
const comment = `OpenLoops route blocked for task ${taskId}: ${reason}. Added no-auto and removed auto:route so route drains do not repeatedly route this task until its project path is fixed.`;
|
|
12415
|
-
const commentResult = runLocalCommand("todos", ["--project",
|
|
12416
|
-
const tagResult = runLocalCommand("todos", ["--project",
|
|
12417
|
-
const untagResult = runLocalCommand("todos", ["--project",
|
|
12763
|
+
const commentResult = runLocalCommand("todos", ["--project", sourceTodosProject, "comment", taskId, comment], { timeoutMs: 30000 });
|
|
12764
|
+
const tagResult = runLocalCommand("todos", ["--project", sourceTodosProject, "tag", taskId, "no-auto"], { timeoutMs: 30000 });
|
|
12765
|
+
const untagResult = runLocalCommand("todos", ["--project", sourceTodosProject, "untag", taskId, "auto:route"], { timeoutMs: 30000 });
|
|
12418
12766
|
const ok = commentResult.ok && tagResult.ok && untagResult.ok;
|
|
12419
12767
|
return {
|
|
12420
12768
|
ok,
|
|
@@ -12430,7 +12778,7 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12430
12778
|
const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
|
|
12431
12779
|
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
12432
12780
|
const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
|
|
12433
|
-
const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
|
|
12781
|
+
const taskListFilter = opts.todosProjectsFromRegistry ? opts.taskList?.trim() : resolveTaskListFilter(todosProject, opts.taskList);
|
|
12434
12782
|
const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
|
|
12435
12783
|
const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
|
|
12436
12784
|
const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
|
|
@@ -12438,7 +12786,7 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12438
12786
|
const ready = loadReadyTodosTasks(opts, scanLimit);
|
|
12439
12787
|
const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
|
|
12440
12788
|
projectId: opts.todosProjectId,
|
|
12441
|
-
|
|
12789
|
+
taskList: taskListFilter,
|
|
12442
12790
|
projectPathPrefix: opts.projectPathPrefix,
|
|
12443
12791
|
tags: requiredTags
|
|
12444
12792
|
}));
|
|
@@ -12451,14 +12799,21 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12451
12799
|
let event;
|
|
12452
12800
|
let result;
|
|
12453
12801
|
try {
|
|
12454
|
-
|
|
12455
|
-
|
|
12802
|
+
const sourceProject = opts.todosProjectsFromRegistry ? taskField(task, ["source_project_path", "sourceProjectPath"]) : undefined;
|
|
12803
|
+
event = taskDrainEvent(task, sourceProject);
|
|
12804
|
+
result = routeTodosTaskEvent(event, {
|
|
12805
|
+
...opts,
|
|
12806
|
+
...sourceProject ? { sourceTodosProjectPath: sourceProject } : {}
|
|
12807
|
+
});
|
|
12456
12808
|
} catch (error) {
|
|
12457
12809
|
const message = error instanceof Error ? error.message : String(error);
|
|
12458
|
-
if (
|
|
12459
|
-
|
|
12460
|
-
|
|
12461
|
-
|
|
12810
|
+
if (isSkippableDrainRouteError(message)) {
|
|
12811
|
+
const sourceTaskProject = opts.todosProjectsFromRegistry ? taskField(task, ["source_project_path", "sourceProjectPath"]) ?? todosProject : todosProject;
|
|
12812
|
+
const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(sourceTaskProject, task, message);
|
|
12813
|
+
result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { sourceTaskUpdate });
|
|
12814
|
+
} else {
|
|
12815
|
+
result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { fatal: true });
|
|
12816
|
+
}
|
|
12462
12817
|
}
|
|
12463
12818
|
results.push(result);
|
|
12464
12819
|
if (result.kind === "created")
|
|
@@ -12485,6 +12840,7 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12485
12840
|
deduped: results.filter((result) => result.kind === "deduped").length,
|
|
12486
12841
|
throttled: results.filter((result) => result.kind === "throttled").length,
|
|
12487
12842
|
skipped: results.filter((result) => result.kind === "skipped").length,
|
|
12843
|
+
fatal: results.filter((result) => result.value.fatal === true).length,
|
|
12488
12844
|
maxDispatch,
|
|
12489
12845
|
source: "todos ready",
|
|
12490
12846
|
dryRun: Boolean(opts.dryRun),
|
|
@@ -12512,6 +12868,7 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12512
12868
|
deduped: report.deduped,
|
|
12513
12869
|
throttled: report.throttled,
|
|
12514
12870
|
skipped: report.skipped,
|
|
12871
|
+
fatal: report.fatal,
|
|
12515
12872
|
maxDispatch: report.maxDispatch,
|
|
12516
12873
|
source: report.source,
|
|
12517
12874
|
dryRun: report.dryRun,
|
|
@@ -12520,7 +12877,7 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12520
12877
|
} : { ...report, evidencePath };
|
|
12521
12878
|
return {
|
|
12522
12879
|
value,
|
|
12523
|
-
human: `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`
|
|
12880
|
+
human: `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped} fatal=${report.fatal}`
|
|
12524
12881
|
};
|
|
12525
12882
|
}
|
|
12526
12883
|
// src/lib/route/route-tasks.ts
|
|
@@ -12797,6 +13154,19 @@ var EVENT_INPUT_OPTION_SPECS = [
|
|
|
12797
13154
|
{ flags: "--event-json <json>", key: "eventJson", kind: "value", description: "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON" }
|
|
12798
13155
|
];
|
|
12799
13156
|
var DRAIN_FILTER_OPTION_SPECS = [
|
|
13157
|
+
{
|
|
13158
|
+
flags: "--todos-projects-from-registry",
|
|
13159
|
+
key: "todosProjectsFromRegistry",
|
|
13160
|
+
kind: "boolean",
|
|
13161
|
+
description: "scan registered todos projects from `todos projects --json` instead of one --todos-project"
|
|
13162
|
+
},
|
|
13163
|
+
{
|
|
13164
|
+
flags: "--todos-project-include <path>",
|
|
13165
|
+
key: "todosProjectInclude",
|
|
13166
|
+
kind: "repeat",
|
|
13167
|
+
description: "include additional registered project path prefixes when scanning via --todos-projects-from-registry",
|
|
13168
|
+
serializeValue: (opts) => listFromRepeatedOpts(opts.todosProjectInclude)
|
|
13169
|
+
},
|
|
12800
13170
|
{ flags: "--todos-project-id <id>", key: "todosProjectId", kind: "value", description: "filter todos ready output to one todos project id" },
|
|
12801
13171
|
{ flags: "--task-list <id-or-slug>", key: "taskList", kind: "value", description: "filter ready tasks to one task-list id, slug, or name" },
|
|
12802
13172
|
{ flags: "--project-path-prefix <path>", key: "projectPathPrefix", kind: "value", description: "filter ready tasks to a project/repo path prefix" },
|
|
@@ -13721,6 +14091,11 @@ function handleRouteDrain(kind, opts) {
|
|
|
13721
14091
|
throw new ValidationError("route drain currently supports kind todos-task");
|
|
13722
14092
|
const result = drainTodosTaskRoutes(opts);
|
|
13723
14093
|
print(result.value, result.human);
|
|
14094
|
+
const fatal = Number(result.value.fatal ?? 0);
|
|
14095
|
+
if (fatal > 0) {
|
|
14096
|
+
console.error(`route drain hit ${fatal} non-skippable task error(s); see evidence file`);
|
|
14097
|
+
process.exitCode = 1;
|
|
14098
|
+
}
|
|
13724
14099
|
}
|
|
13725
14100
|
addRouteEventOptions(routes.command("preview <kind>").description("(deprecated: use 'routes create <kind> --dry-run') preview a route-created workflow invocation without storing it")).action(runAction(async (kind, opts) => handleRouteEvent(kind, { ...opts, dryRun: true })));
|
|
13726
14101
|
addRouteEventOptions(routes.command("create <kind>").description("create a route workflow invocation and admit it when capacity allows"), { dryRunDescription: "preview the generated workflow invocation and loop without storing anything" }).action(runAction(async (kind, opts) => handleRouteEvent(kind, opts)));
|
|
@@ -13922,7 +14297,7 @@ workflows.command("runs [idOrName]").description("list workflow runs, optionally
|
|
|
13922
14297
|
const store = new Store;
|
|
13923
14298
|
try {
|
|
13924
14299
|
const workflow = idOrName ? store.requireWorkflow(idOrName) : undefined;
|
|
13925
|
-
const runs = store.listWorkflowRuns({ workflowId: workflow?.id, limit:
|
|
14300
|
+
const runs = store.listWorkflowRuns({ workflowId: workflow?.id, limit: positiveInteger(opts.limit, "--limit") ?? 50 });
|
|
13926
14301
|
if (isJson())
|
|
13927
14302
|
print(runs.map(publicWorkflowRun));
|
|
13928
14303
|
else {
|
|
@@ -13937,7 +14312,7 @@ workflows.command("runs [idOrName]").description("list workflow runs, optionally
|
|
|
13937
14312
|
workflows.command("events <runId>").description("list step/lifecycle events for a workflow run").option("--limit <n>", "limit", "200").action(runAction((runId, opts) => {
|
|
13938
14313
|
const store = new Store;
|
|
13939
14314
|
try {
|
|
13940
|
-
const runEvents = store.listWorkflowEvents(runId,
|
|
14315
|
+
const runEvents = store.listWorkflowEvents(runId, positiveInteger(opts.limit, "--limit") ?? 200);
|
|
13941
14316
|
if (isJson())
|
|
13942
14317
|
print(runEvents.map(publicWorkflowEvent));
|
|
13943
14318
|
else {
|
|
@@ -14178,7 +14553,7 @@ program.command("runs [idOrName]").description("list recent runs, optionally for
|
|
|
14178
14553
|
const store = new Store;
|
|
14179
14554
|
try {
|
|
14180
14555
|
const loop = idOrName ? store.requireLoop(idOrName) : undefined;
|
|
14181
|
-
const runs = store.listRuns({ loopId: loop?.id, limit:
|
|
14556
|
+
const runs = store.listRuns({ loopId: loop?.id, limit: positiveInteger(opts.limit, "--limit") ?? 50 });
|
|
14182
14557
|
if (isJson())
|
|
14183
14558
|
print(runs.map((run) => publicRun(run, opts.showOutput)));
|
|
14184
14559
|
else {
|
|
@@ -14195,7 +14570,7 @@ program.command("runs [idOrName]").description("list recent runs, optionally for
|
|
|
14195
14570
|
program.command("expectations [idOrName]").description("evaluate deterministic loop expectations without mutating external task systems").option("--limit <n>", "maximum loops to inspect when no loop is specified", "200").action(runAction((idOrName, opts) => {
|
|
14196
14571
|
const store = new Store;
|
|
14197
14572
|
try {
|
|
14198
|
-
const loops = idOrName ? [store.requireLoop(idOrName)] : store.listLoops({ limit:
|
|
14573
|
+
const loops = idOrName ? [store.requireLoop(idOrName)] : store.listLoops({ limit: positiveInteger(opts.limit, "--limit") ?? 200 });
|
|
14199
14574
|
const values = loops.map((loop) => expectationForLoop(store, loop));
|
|
14200
14575
|
if (isJson())
|
|
14201
14576
|
console.log(JSON.stringify(idOrName ? values[0] : values, null, 2));
|
|
@@ -14233,7 +14608,7 @@ var health = program.command("health").description("summarize loop health and la
|
|
|
14233
14608
|
health.command("route-tasks").description("upsert deduped todos tasks for failed loop health expectations").option("--project <path>", "todos project path", defaultLoopsProject()).option("--task-list <slug>", "todos task-list slug", "loop-error-self-heal").option("--limit <n>", "maximum loops to inspect", "200").option("--max-actions <n>", "maximum todos tasks to upsert", "5").option("--include-inactive", "also route stopped or expired loops").option("--auto-route", "opt routed tasks into task-created headless worker/verifier automation").option("--route-project-path <path>", "fallback project path for --auto-route when the failed loop has no cwd").option("--evidence-dir <path>", "write the route result JSON to this directory").option("--dry-run", "print intended task upserts without mutating todos").action(runAction((opts) => {
|
|
14234
14609
|
const store = new Store;
|
|
14235
14610
|
try {
|
|
14236
|
-
const report = buildHealthReport(store, { limit:
|
|
14611
|
+
const report = buildHealthReport(store, { limit: positiveInteger(opts.limit, "--limit") ?? 200, includeInactive: Boolean(opts.includeInactive) });
|
|
14237
14612
|
const failures = report.expectations.filter((entry) => !entry.ok && entry.recommendedTask);
|
|
14238
14613
|
const result = upsertRouteTasks({
|
|
14239
14614
|
project: opts.project,
|
|
@@ -14246,7 +14621,7 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
|
|
|
14246
14621
|
autoRoute: Boolean(opts.autoRoute),
|
|
14247
14622
|
routeProjectPath: opts.routeProjectPath
|
|
14248
14623
|
}),
|
|
14249
|
-
maxActions:
|
|
14624
|
+
maxActions: positiveInteger(opts.maxActions, "--max-actions") ?? 5,
|
|
14250
14625
|
dryRun: Boolean(opts.dryRun),
|
|
14251
14626
|
autoRoute: Boolean(opts.autoRoute),
|
|
14252
14627
|
routeProjectPath: opts.routeProjectPath,
|
|
@@ -14300,7 +14675,7 @@ hygiene.command("names").description("check or apply canonical machine-/repo-pre
|
|
|
14300
14675
|
apply: false,
|
|
14301
14676
|
includeStopped: Boolean(opts.includeStopped),
|
|
14302
14677
|
includeInactive: Boolean(opts.includeInactive),
|
|
14303
|
-
limit:
|
|
14678
|
+
limit: positiveInteger(opts.limit, "--limit") ?? 1000
|
|
14304
14679
|
});
|
|
14305
14680
|
let outputReport = report;
|
|
14306
14681
|
const backupPath = opts.apply && report.changed > 0 ? backupLoopsDatabase("name-hygiene") : undefined;
|
|
@@ -14309,7 +14684,7 @@ hygiene.command("names").description("check or apply canonical machine-/repo-pre
|
|
|
14309
14684
|
apply: true,
|
|
14310
14685
|
includeStopped: Boolean(opts.includeStopped),
|
|
14311
14686
|
includeInactive: Boolean(opts.includeInactive),
|
|
14312
|
-
limit:
|
|
14687
|
+
limit: positiveInteger(opts.limit, "--limit") ?? 1000
|
|
14313
14688
|
});
|
|
14314
14689
|
} else if (opts.apply) {
|
|
14315
14690
|
outputReport = { ...report, applied: true };
|
|
@@ -14336,7 +14711,7 @@ hygiene.command("duplicates").description("detect duplicate/overlapping loops wi
|
|
|
14336
14711
|
try {
|
|
14337
14712
|
const report = buildDuplicateOverlapReport(store, {
|
|
14338
14713
|
includeInactive: Boolean(opts.includeInactive),
|
|
14339
|
-
limit:
|
|
14714
|
+
limit: positiveInteger(opts.limit, "--limit") ?? 1000
|
|
14340
14715
|
});
|
|
14341
14716
|
if (isJson())
|
|
14342
14717
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -14358,7 +14733,7 @@ hygiene.command("scripts").description("inventory loops still backed by local ~/
|
|
|
14358
14733
|
const report = buildScriptInventoryReport(store, {
|
|
14359
14734
|
scriptsDir: opts.scriptsDir,
|
|
14360
14735
|
includeInactive: Boolean(opts.includeInactive),
|
|
14361
|
-
limit:
|
|
14736
|
+
limit: positiveInteger(opts.limit, "--limit") ?? 1000
|
|
14362
14737
|
});
|
|
14363
14738
|
if (isJson())
|
|
14364
14739
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -14380,7 +14755,7 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
|
|
|
14380
14755
|
const route = buildHygieneRouteTasks(store, {
|
|
14381
14756
|
checks,
|
|
14382
14757
|
includeInactive: Boolean(opts.includeInactive),
|
|
14383
|
-
limit:
|
|
14758
|
+
limit: positiveInteger(opts.limit, "--limit") ?? 1000,
|
|
14384
14759
|
scriptsDir: opts.scriptsDir
|
|
14385
14760
|
});
|
|
14386
14761
|
const result = upsertRouteTasks({
|
|
@@ -14394,7 +14769,7 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
|
|
|
14394
14769
|
autoRoute: Boolean(opts.autoRoute),
|
|
14395
14770
|
routeProjectPath: opts.routeProjectPath
|
|
14396
14771
|
}),
|
|
14397
|
-
maxActions:
|
|
14772
|
+
maxActions: positiveInteger(opts.maxActions, "--max-actions") ?? 10,
|
|
14398
14773
|
dryRun: Boolean(opts.dryRun),
|
|
14399
14774
|
autoRoute: Boolean(opts.autoRoute),
|
|
14400
14775
|
routeProjectPath: opts.routeProjectPath,
|
|
@@ -14434,7 +14809,7 @@ program.command("stop <idOrName>").description("stop a loop and clear its next s
|
|
|
14434
14809
|
program.command("rename <idOrName> <newName>").description("rename a loop without changing its id, schedule, runs, or history").action(runAction((idOrName, newName) => {
|
|
14435
14810
|
const store = new Store;
|
|
14436
14811
|
try {
|
|
14437
|
-
const loop = store.
|
|
14812
|
+
const loop = store.requireUniqueLoop(idOrName);
|
|
14438
14813
|
const oldName = loop.name;
|
|
14439
14814
|
const trimmed = String(newName).trim();
|
|
14440
14815
|
if (!trimmed)
|
|
@@ -14471,10 +14846,17 @@ backup=${backupPath ?? "skipped (recent rename backup exists)"}`);
|
|
|
14471
14846
|
function updateStatus(idOrName, status) {
|
|
14472
14847
|
const store = new Store;
|
|
14473
14848
|
try {
|
|
14474
|
-
const loop = store.
|
|
14849
|
+
const loop = store.requireUniqueLoop(idOrName);
|
|
14475
14850
|
if (loop.archivedAt)
|
|
14476
14851
|
throw new Error(`loop is archived; run 'loops unarchive ${idOrName}' first`);
|
|
14477
|
-
|
|
14852
|
+
let nextRunAt = loop.nextRunAt;
|
|
14853
|
+
if (status === "stopped") {
|
|
14854
|
+
nextRunAt = undefined;
|
|
14855
|
+
} else if (status === "active" && !loop.nextRunAt) {
|
|
14856
|
+
const now = new Date;
|
|
14857
|
+
nextRunAt = computeNextAfter(loop.schedule, now, now);
|
|
14858
|
+
}
|
|
14859
|
+
const updated = store.updateLoop(loop.id, { status, nextRunAt });
|
|
14478
14860
|
print(publicLoop(updated), `${updated.id} ${updated.status}`);
|
|
14479
14861
|
} finally {
|
|
14480
14862
|
store.close();
|
|
@@ -14483,7 +14865,7 @@ function updateStatus(idOrName, status) {
|
|
|
14483
14865
|
program.command("remove <idOrName>").alias("rm").description("delete a loop and its run history").action(runAction((idOrName) => {
|
|
14484
14866
|
const store = new Store;
|
|
14485
14867
|
try {
|
|
14486
|
-
const removed = store.deleteLoop(idOrName);
|
|
14868
|
+
const removed = store.deleteLoop(store.requireUniqueLoop(idOrName).id);
|
|
14487
14869
|
print({ removed }, removed ? "removed" : "not removed");
|
|
14488
14870
|
} finally {
|
|
14489
14871
|
store.close();
|
|
@@ -14510,7 +14892,7 @@ program.command("unarchive <idOrName>").alias("restore").description("restore an
|
|
|
14510
14892
|
program.command("run-now <idOrName>").description("claim and execute one loop run immediately").option("--show-output", "show stdout/stderr").action(runAction(async (idOrName, opts) => {
|
|
14511
14893
|
const store = new Store;
|
|
14512
14894
|
try {
|
|
14513
|
-
const loop = store.
|
|
14895
|
+
const loop = store.requireUniqueLoop(idOrName);
|
|
14514
14896
|
if (loop.archivedAt)
|
|
14515
14897
|
throw new Error(`loop is archived; run 'loops unarchive ${idOrName}' before running it`);
|
|
14516
14898
|
const runnerId = `manual:${process.pid}`;
|
|
@@ -14690,15 +15072,16 @@ ${result.enableResults.map((item) => `${item.command} -> ${item.status === 0 ? "
|
|
|
14690
15072
|
${result.instructions.join(`
|
|
14691
15073
|
`)}${enableText}`);
|
|
14692
15074
|
}));
|
|
14693
|
-
daemon.command("logs").description("print the tail of the daemon log").option("-n, --lines <n>", "lines", "80").action(runAction((opts) => {
|
|
15075
|
+
daemon.command("logs").description("print the tail of the daemon log").option("-n, --lines <n>", "lines", "80").option("--tail <n>", "alias for --lines").action(runAction((opts) => {
|
|
14694
15076
|
const path = daemonLogPath();
|
|
14695
15077
|
if (!existsSync10(path)) {
|
|
14696
15078
|
console.log("");
|
|
14697
15079
|
return;
|
|
14698
15080
|
}
|
|
15081
|
+
const count = positiveInteger(opts.tail ?? opts.lines, opts.tail !== undefined ? "--tail" : "--lines") ?? 80;
|
|
14699
15082
|
const lines = readFileSync10(path, "utf8").trimEnd().split(`
|
|
14700
15083
|
`);
|
|
14701
|
-
console.log(lines.slice(-
|
|
15084
|
+
console.log(lines.slice(-count).join(`
|
|
14702
15085
|
`));
|
|
14703
15086
|
}));
|
|
14704
15087
|
await program.parseAsync(process.argv);
|