@hasna/loops 0.3.56 → 0.3.58
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 +188 -14
- package/dist/cli/index.js +1421 -176
- package/dist/daemon/index.js +164 -20
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4049 -2874
- package/dist/lib/executor.d.ts +2 -0
- package/dist/lib/health.d.ts +2 -2
- package/dist/lib/store.d.ts +4 -0
- package/dist/lib/store.js +61 -8
- package/dist/lib/templates.d.ts +4 -0
- package/dist/mcp/index.d.ts +12 -0
- package/dist/mcp/index.js +5841 -0
- package/dist/sdk/index.js +155 -17
- package/docs/AUTOMATION_RUNTIME_DESIGN.md +228 -0
- package/docs/USAGE.md +123 -15
- package/package.json +9 -3
package/dist/cli/index.js
CHANGED
|
@@ -461,6 +461,9 @@ function validateTarget(value, label, opts) {
|
|
|
461
461
|
}
|
|
462
462
|
if (value.model !== undefined)
|
|
463
463
|
assertString(value.model, `${label}.model`);
|
|
464
|
+
if (value.provider === "opencode" && (typeof value.model !== "string" || value.model.trim() === "")) {
|
|
465
|
+
throw new Error(`${label}.model is required for provider opencode; pass a provider/model id such as openrouter/google/gemini-2.5-flash`);
|
|
466
|
+
}
|
|
464
467
|
if (value.variant !== undefined)
|
|
465
468
|
assertString(value.variant, `${label}.variant`);
|
|
466
469
|
if (value.agent !== undefined)
|
|
@@ -1257,12 +1260,21 @@ class Store {
|
|
|
1257
1260
|
if (!row)
|
|
1258
1261
|
throw new Error("daemon lease lost");
|
|
1259
1262
|
}
|
|
1263
|
+
assertNoNestedWorkflowGoal(target, goal) {
|
|
1264
|
+
if (!goal || target.type !== "workflow")
|
|
1265
|
+
return;
|
|
1266
|
+
const workflow = this.getWorkflow(target.workflowId);
|
|
1267
|
+
if (workflow?.goal) {
|
|
1268
|
+
throw new Error(`workflow loop cannot define a loop-level goal when workflow ${workflow.name} already has a top-level goal; remove one goal wrapper`);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1260
1271
|
createLoop(input, from = new Date) {
|
|
1261
1272
|
const now = nowIso();
|
|
1262
1273
|
const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
|
|
1263
1274
|
name: "loop-target-validation",
|
|
1264
1275
|
steps: [{ id: "target", target: input.target }]
|
|
1265
1276
|
}).steps[0].target;
|
|
1277
|
+
this.assertNoNestedWorkflowGoal(target, input.goal);
|
|
1266
1278
|
const loop = {
|
|
1267
1279
|
id: genId(),
|
|
1268
1280
|
name: input.name,
|
|
@@ -1434,6 +1446,9 @@ class Store {
|
|
|
1434
1446
|
if (this.hasRunningRun(current.id))
|
|
1435
1447
|
throw new Error(`refusing to retarget running loop: ${current.id}`);
|
|
1436
1448
|
const workflow = this.requireWorkflow(workflowId);
|
|
1449
|
+
if (current.goal && workflow.goal) {
|
|
1450
|
+
throw new Error(`workflow loop cannot retarget ${current.name} to workflow ${workflow.name} because both define top-level goals`);
|
|
1451
|
+
}
|
|
1437
1452
|
const target = { ...current.target, workflowId: workflow.id };
|
|
1438
1453
|
if (opts.workflowTimeoutMs !== undefined)
|
|
1439
1454
|
target.timeoutMs = opts.workflowTimeoutMs;
|
|
@@ -1472,6 +1487,9 @@ class Store {
|
|
|
1472
1487
|
throw new Error(`loop is not a workflow loop: ${idOrName}`);
|
|
1473
1488
|
if (this.hasRunningRun(current.id))
|
|
1474
1489
|
throw new Error(`refusing to retarget running loop: ${current.id}`);
|
|
1490
|
+
if (current.goal && normalized.goal) {
|
|
1491
|
+
throw new Error(`workflow loop cannot retarget ${current.name} to a workflow that also defines a top-level goal`);
|
|
1492
|
+
}
|
|
1475
1493
|
const previousWorkflow = this.requireWorkflow(current.target.workflowId);
|
|
1476
1494
|
const workflow = {
|
|
1477
1495
|
id: genId(),
|
|
@@ -1763,7 +1781,7 @@ class Store {
|
|
|
1763
1781
|
if (!sourceDedupeKey)
|
|
1764
1782
|
throw new Error("cannot refresh workflow invocation without sourceRef.dedupeKey");
|
|
1765
1783
|
const now = nowIso();
|
|
1766
|
-
const claimableStatuses = ["queued", "deferred"
|
|
1784
|
+
const claimableStatuses = ["queued", "deferred"];
|
|
1767
1785
|
const statusBindings = Object.fromEntries(claimableStatuses.map((status, index) => [`$status${index}`, status]));
|
|
1768
1786
|
const placeholders = claimableStatuses.map((_, index) => `$status${index}`).join(",");
|
|
1769
1787
|
const result = this.db.query(`UPDATE workflow_invocations
|
|
@@ -1844,28 +1862,35 @@ class Store {
|
|
|
1844
1862
|
project_group=excluded.project_group,
|
|
1845
1863
|
priority=excluded.priority,
|
|
1846
1864
|
status=CASE
|
|
1847
|
-
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
|
|
1865
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
|
|
1848
1866
|
THEN workflow_work_items.status
|
|
1849
1867
|
ELSE excluded.status
|
|
1850
1868
|
END,
|
|
1851
1869
|
workflow_id=CASE
|
|
1852
|
-
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
|
|
1870
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_id
|
|
1853
1871
|
ELSE NULL
|
|
1854
1872
|
END,
|
|
1855
1873
|
loop_id=CASE
|
|
1856
|
-
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
|
|
1874
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.loop_id
|
|
1857
1875
|
ELSE NULL
|
|
1858
1876
|
END,
|
|
1859
1877
|
workflow_run_id=CASE
|
|
1860
|
-
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
|
|
1878
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_run_id
|
|
1861
1879
|
ELSE NULL
|
|
1862
1880
|
END,
|
|
1863
1881
|
lease_expires_at=CASE
|
|
1864
|
-
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
|
|
1882
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.lease_expires_at
|
|
1865
1883
|
ELSE NULL
|
|
1866
1884
|
END,
|
|
1867
1885
|
next_attempt_at=excluded.next_attempt_at,
|
|
1868
|
-
last_reason=
|
|
1886
|
+
last_reason=CASE
|
|
1887
|
+
WHEN workflow_work_items.attempts > 0
|
|
1888
|
+
AND workflow_work_items.status IN ('queued', 'deferred')
|
|
1889
|
+
AND workflow_work_items.last_reason IS NOT NULL
|
|
1890
|
+
AND excluded.last_reason IS NOT NULL
|
|
1891
|
+
THEN workflow_work_items.last_reason || '; ' || excluded.last_reason
|
|
1892
|
+
ELSE COALESCE(excluded.last_reason, workflow_work_items.last_reason)
|
|
1893
|
+
END,
|
|
1869
1894
|
updated_at=excluded.updated_at`).run({
|
|
1870
1895
|
$id: id,
|
|
1871
1896
|
$routeKey: input.routeKey,
|
|
@@ -1918,11 +1943,39 @@ class Store {
|
|
|
1918
1943
|
const projectGroup = args.projectGroup ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_group = ?`).get(...active, args.projectGroup)?.count ?? 0 : undefined;
|
|
1919
1944
|
return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
|
|
1920
1945
|
}
|
|
1946
|
+
requeueWorkflowWorkItem(id, patch = {}) {
|
|
1947
|
+
const current = this.getWorkflowWorkItem(id);
|
|
1948
|
+
if (!current)
|
|
1949
|
+
throw new Error(`workflow work item not found: ${id}`);
|
|
1950
|
+
const requeueableStatuses = ["succeeded", "failed", "dead_letter", "cancelled"];
|
|
1951
|
+
if (!requeueableStatuses.includes(current.status)) {
|
|
1952
|
+
throw new Error(`workflow work item is not requeueable: ${id} status=${current.status}`);
|
|
1953
|
+
}
|
|
1954
|
+
const now = nowIso();
|
|
1955
|
+
const reason = patch.reason?.trim() || `requeued from ${current.status}`;
|
|
1956
|
+
const placeholders = requeueableStatuses.map(() => "?").join(",");
|
|
1957
|
+
const res = this.db.query(`UPDATE workflow_work_items
|
|
1958
|
+
SET status='queued', workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
|
|
1959
|
+
next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
|
|
1960
|
+
WHERE id=? AND status IN (${placeholders})`).run(reason, now, id, ...requeueableStatuses);
|
|
1961
|
+
const item = this.getWorkflowWorkItem(id);
|
|
1962
|
+
if (!item)
|
|
1963
|
+
throw new Error(`workflow work item not found after requeue: ${id}`);
|
|
1964
|
+
if (res.changes !== 1)
|
|
1965
|
+
throw new Error(`workflow work item was not requeued: ${id} status=${item.status}`);
|
|
1966
|
+
return item;
|
|
1967
|
+
}
|
|
1921
1968
|
admitWorkflowWorkItem(id, patch) {
|
|
1922
1969
|
const now = nowIso();
|
|
1923
1970
|
const res = this.db.query(`UPDATE workflow_work_items
|
|
1924
1971
|
SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
|
|
1925
|
-
next_attempt_at=NULL,
|
|
1972
|
+
next_attempt_at=NULL,
|
|
1973
|
+
lease_expires_at=NULL,
|
|
1974
|
+
last_reason=CASE
|
|
1975
|
+
WHEN last_reason IS NOT NULL AND $reason IS NOT NULL THEN last_reason || '; ' || $reason
|
|
1976
|
+
ELSE COALESCE($reason, last_reason)
|
|
1977
|
+
END,
|
|
1978
|
+
updated_at=$updated
|
|
1926
1979
|
WHERE id=$id AND status IN ('queued', 'deferred')`).run({
|
|
1927
1980
|
$id: id,
|
|
1928
1981
|
$workflowId: patch.workflowId,
|
|
@@ -3105,7 +3158,7 @@ class Store {
|
|
|
3105
3158
|
|
|
3106
3159
|
// src/cli/index.ts
|
|
3107
3160
|
import { createHash as createHash3, randomUUID } from "crypto";
|
|
3108
|
-
import { closeSync, existsSync as
|
|
3161
|
+
import { closeSync, existsSync as existsSync6, mkdirSync as mkdirSync7, mkdtempSync as mkdtempSync2, openSync as openSync2, readFileSync as readFileSync5, realpathSync, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
|
|
3109
3162
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
3110
3163
|
import { dirname as dirname4, join as join6, resolve as resolve3 } from "path";
|
|
3111
3164
|
import { tmpdir as tmpdir2 } from "os";
|
|
@@ -3580,6 +3633,9 @@ function assertSupportedAgentOptions(target) {
|
|
|
3580
3633
|
assertStringOption(target.agent, `${target.provider}.agent`);
|
|
3581
3634
|
assertStringOption(target.authProfile, `${target.provider}.authProfile`);
|
|
3582
3635
|
assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
|
|
3636
|
+
if (target.provider === "opencode" && (target.model === undefined || target.model.trim() === "")) {
|
|
3637
|
+
throw new Error("opencode.model is required; pass a provider/model id such as openrouter/google/gemini-2.5-flash");
|
|
3638
|
+
}
|
|
3583
3639
|
if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
|
|
3584
3640
|
throw new Error(`${target.provider}.configIsolation must be safe or none`);
|
|
3585
3641
|
}
|
|
@@ -4352,6 +4408,78 @@ function sameBlockerKey(values) {
|
|
|
4352
4408
|
return values.map((value) => value.trim()).filter(Boolean).join(`
|
|
4353
4409
|
`) || "goal completion remains unproven";
|
|
4354
4410
|
}
|
|
4411
|
+
function planStatusForGoal(goal) {
|
|
4412
|
+
if (goal.status === "usageLimited")
|
|
4413
|
+
return "blocked";
|
|
4414
|
+
return goal.status;
|
|
4415
|
+
}
|
|
4416
|
+
function syncReadyFlags(store, goal, nodes, opts) {
|
|
4417
|
+
const withReady = updateReadyFlags(nodes, planStatusForGoal(goal));
|
|
4418
|
+
for (const node of withReady) {
|
|
4419
|
+
const current = nodes.find((entry) => entry.key === node.key);
|
|
4420
|
+
if (current && current.ready !== node.ready) {
|
|
4421
|
+
store.updateGoalPlanNode(goal.goalId, node.key, { ready: node.ready }, { daemonLeaseId: opts.daemonLeaseId });
|
|
4422
|
+
}
|
|
4423
|
+
}
|
|
4424
|
+
return withReady;
|
|
4425
|
+
}
|
|
4426
|
+
function summarizeLabels(values, limit = 5) {
|
|
4427
|
+
if (values.length <= limit)
|
|
4428
|
+
return values.join(", ");
|
|
4429
|
+
return `${values.slice(0, limit).join(", ")} and ${values.length - limit} more`;
|
|
4430
|
+
}
|
|
4431
|
+
function noReadyDiagnostic(goal, nodes) {
|
|
4432
|
+
const planStatus = planStatusForGoal(goal);
|
|
4433
|
+
const byKey = new Map(nodes.map((node) => [node.key, node]));
|
|
4434
|
+
const pendingNodes = nodes.filter((node) => node.status === "pending");
|
|
4435
|
+
const incompleteNodes = nodes.filter((node) => node.status !== "complete");
|
|
4436
|
+
const pendingDetails = pendingNodes.map((node) => ({
|
|
4437
|
+
key: node.key,
|
|
4438
|
+
status: node.status,
|
|
4439
|
+
ready: node.ready,
|
|
4440
|
+
tokenBudget: node.tokenBudget,
|
|
4441
|
+
tokensUsed: node.tokensUsed,
|
|
4442
|
+
budgetExhausted: nodeBudgetExhausted(node),
|
|
4443
|
+
unmetDependencies: node.dependsOn.map((dependency) => ({ key: dependency, status: byKey.get(dependency)?.status ?? "missing" })).filter((dependency) => dependency.status !== "complete")
|
|
4444
|
+
}));
|
|
4445
|
+
const incompleteDetails = incompleteNodes.map((node) => ({
|
|
4446
|
+
key: node.key,
|
|
4447
|
+
status: node.status,
|
|
4448
|
+
ready: node.ready
|
|
4449
|
+
}));
|
|
4450
|
+
let owner = "goal-plan";
|
|
4451
|
+
let cause = "goal plan has no ready nodes";
|
|
4452
|
+
if (planStatus !== "active") {
|
|
4453
|
+
owner = "goal";
|
|
4454
|
+
cause = `goal status is ${goal.status}`;
|
|
4455
|
+
} else if (pendingNodes.length === 0) {
|
|
4456
|
+
owner = "goal-plan";
|
|
4457
|
+
cause = `goal plan has no pending runnable nodes; incomplete nodes: ${summarizeLabels(incompleteDetails.map((node) => `${node.key}:${node.status}`))}`;
|
|
4458
|
+
} else if (pendingDetails.every((node) => node.budgetExhausted)) {
|
|
4459
|
+
owner = "goal-plan-node";
|
|
4460
|
+
cause = `all pending nodes are budget-exhausted: ${summarizeLabels(pendingDetails.map((node) => node.key))}`;
|
|
4461
|
+
} else {
|
|
4462
|
+
const missingDependencies = pendingDetails.flatMap((node) => node.unmetDependencies.filter((dependency) => dependency.status === "missing").map((dependency) => `${node.key}->${dependency.key}`));
|
|
4463
|
+
if (missingDependencies.length > 0) {
|
|
4464
|
+
owner = "goal-plan";
|
|
4465
|
+
cause = `pending nodes reference missing dependencies: ${summarizeLabels(missingDependencies)}`;
|
|
4466
|
+
} else if (pendingDetails.every((node) => node.unmetDependencies.length > 0 || node.budgetExhausted)) {
|
|
4467
|
+
owner = "goal-plan-node";
|
|
4468
|
+
const waiting = pendingDetails.filter((node) => node.unmetDependencies.length > 0).map((node) => `${node.key} waits on ${node.unmetDependencies.map((dependency) => `${dependency.key}:${dependency.status}`).join(",")}`);
|
|
4469
|
+
const exhausted = pendingDetails.filter((node) => node.budgetExhausted).map((node) => `${node.key} budget exhausted`);
|
|
4470
|
+
cause = `pending nodes are blocked by prerequisites: ${summarizeLabels([...waiting, ...exhausted])}`;
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
const blocker = `${cause}; owner=${owner}`;
|
|
4474
|
+
return {
|
|
4475
|
+
owner,
|
|
4476
|
+
blocker,
|
|
4477
|
+
planStatus,
|
|
4478
|
+
rollup: rollupSummary(nodes),
|
|
4479
|
+
pendingNodes: pendingDetails,
|
|
4480
|
+
incompleteNodes: incompleteDetails
|
|
4481
|
+
};
|
|
4482
|
+
}
|
|
4355
4483
|
function metadataFor(goal, node, context) {
|
|
4356
4484
|
return {
|
|
4357
4485
|
loopId: context?.loopId,
|
|
@@ -4412,13 +4540,14 @@ async function planGoal(store, goal, spec, model, opts) {
|
|
|
4412
4540
|
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
4413
4541
|
return store.createGoalPlanNodes(goal.goalId, rawNodes, { daemonLeaseId: opts.daemonLeaseId });
|
|
4414
4542
|
}
|
|
4415
|
-
function stdoutFor(goal, nodes, evidence, validation) {
|
|
4543
|
+
function stdoutFor(goal, nodes, evidence, validation, diagnostics) {
|
|
4416
4544
|
return JSON.stringify({
|
|
4417
4545
|
goal,
|
|
4418
4546
|
rollup: rollupSummary(nodes),
|
|
4419
4547
|
nodes,
|
|
4420
4548
|
evidence,
|
|
4421
|
-
validation
|
|
4549
|
+
validation,
|
|
4550
|
+
diagnostics
|
|
4422
4551
|
}, null, 2);
|
|
4423
4552
|
}
|
|
4424
4553
|
async function runGoal(store, input, opts = {}) {
|
|
@@ -4451,6 +4580,7 @@ async function runGoal(store, input, opts = {}) {
|
|
|
4451
4580
|
let validation;
|
|
4452
4581
|
let lastBlocker = "";
|
|
4453
4582
|
let repeatedBlockerCount = 0;
|
|
4583
|
+
let lastDiagnostic;
|
|
4454
4584
|
if (budgetExhausted(goal)) {
|
|
4455
4585
|
goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
|
|
4456
4586
|
return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted after planning", startedAt);
|
|
@@ -4461,13 +4591,13 @@ async function runGoal(store, input, opts = {}) {
|
|
|
4461
4591
|
return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal cancelled", startedAt);
|
|
4462
4592
|
}
|
|
4463
4593
|
goal = store.requireGoal(goal.goalId);
|
|
4464
|
-
nodes = store.listGoalPlanNodes(goal.goalId);
|
|
4594
|
+
nodes = syncReadyFlags(store, goal, store.listGoalPlanNodes(goal.goalId), opts);
|
|
4465
4595
|
if (budgetExhausted(goal)) {
|
|
4466
4596
|
goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
|
|
4467
4597
|
return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted", startedAt);
|
|
4468
4598
|
}
|
|
4469
4599
|
const readyKeys = readyNodeKeys({
|
|
4470
|
-
status: goal
|
|
4600
|
+
status: planStatusForGoal(goal),
|
|
4471
4601
|
nodes
|
|
4472
4602
|
});
|
|
4473
4603
|
if (readyKeys.length > 0) {
|
|
@@ -4566,7 +4696,9 @@ ${result.stderr}`);
|
|
|
4566
4696
|
}
|
|
4567
4697
|
continue;
|
|
4568
4698
|
}
|
|
4569
|
-
const
|
|
4699
|
+
const diagnostic = noReadyDiagnostic(goal, nodes);
|
|
4700
|
+
lastDiagnostic = diagnostic;
|
|
4701
|
+
const blocker = diagnostic.blocker;
|
|
4570
4702
|
if (blocker === lastBlocker)
|
|
4571
4703
|
repeatedBlockerCount += 1;
|
|
4572
4704
|
else {
|
|
@@ -4578,15 +4710,16 @@ ${result.stderr}`);
|
|
|
4578
4710
|
turn,
|
|
4579
4711
|
phase: "status",
|
|
4580
4712
|
status: repeatedBlockerCount >= 3 ? "blocked" : "active",
|
|
4581
|
-
evidence:
|
|
4713
|
+
evidence: diagnostic
|
|
4582
4714
|
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
4583
4715
|
if (repeatedBlockerCount >= 3) {
|
|
4584
4716
|
goal = store.updateGoalStatus(goal.goalId, "blocked", { daemonLeaseId: opts.daemonLeaseId });
|
|
4585
|
-
return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation), blocker, startedAt);
|
|
4717
|
+
return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation, diagnostic), blocker, startedAt);
|
|
4586
4718
|
}
|
|
4587
4719
|
}
|
|
4588
4720
|
goal = store.updateGoalStatus(goal.goalId, "usageLimited", { daemonLeaseId: opts.daemonLeaseId });
|
|
4589
|
-
|
|
4721
|
+
const exhaustedError = lastDiagnostic?.blocker ?? (lastBlocker ? `${lastBlocker}; max turns exhausted` : "goal max turns exhausted");
|
|
4722
|
+
return resultFromGoal(goal, "failed", stdoutFor(goal, store.listGoalPlanNodes(goal.goalId), evidence, validation, lastDiagnostic), exhaustedError, startedAt);
|
|
4590
4723
|
}
|
|
4591
4724
|
|
|
4592
4725
|
// src/lib/workflow-runner.ts
|
|
@@ -4615,6 +4748,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
4615
4748
|
const workflowWithoutGoal = { ...workflow, goal: undefined };
|
|
4616
4749
|
return runGoal(store, workflow.goal, {
|
|
4617
4750
|
...opts,
|
|
4751
|
+
model: opts.goalModel,
|
|
4618
4752
|
context: {
|
|
4619
4753
|
loopId: opts.loop?.id,
|
|
4620
4754
|
loopName: opts.loop?.name,
|
|
@@ -4707,6 +4841,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
4707
4841
|
if (step.goal) {
|
|
4708
4842
|
result = await runGoal(store, step.goal, {
|
|
4709
4843
|
...opts,
|
|
4844
|
+
model: opts.goalModel,
|
|
4710
4845
|
target: targetWithStepAccount(step),
|
|
4711
4846
|
signal: controller.signal,
|
|
4712
4847
|
context: {
|
|
@@ -4848,6 +4983,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
4848
4983
|
if (loop.goal) {
|
|
4849
4984
|
return runGoal(store, loop.goal, {
|
|
4850
4985
|
...opts,
|
|
4986
|
+
model: opts.goalModel,
|
|
4851
4987
|
target: loop.target,
|
|
4852
4988
|
context: {
|
|
4853
4989
|
loopId: loop.id,
|
|
@@ -4869,8 +5005,10 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
4869
5005
|
}
|
|
4870
5006
|
}
|
|
4871
5007
|
if (loop.goal) {
|
|
5008
|
+
const workflowForLoopGoal = workflow.goal ? { ...workflow, goal: undefined } : workflow;
|
|
4872
5009
|
return runGoal(store, loop.goal, {
|
|
4873
5010
|
...opts,
|
|
5011
|
+
model: opts.goalModel,
|
|
4874
5012
|
context: {
|
|
4875
5013
|
loopId: loop.id,
|
|
4876
5014
|
loopName: loop.name,
|
|
@@ -4879,7 +5017,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
4879
5017
|
workflowId: workflow.id,
|
|
4880
5018
|
workflowName: workflow.name
|
|
4881
5019
|
},
|
|
4882
|
-
executeNode: async (node) => executeWorkflow(store,
|
|
5020
|
+
executeNode: async (node) => executeWorkflow(store, workflowForLoopGoal, {
|
|
4883
5021
|
...opts,
|
|
4884
5022
|
loop,
|
|
4885
5023
|
loopRun: run,
|
|
@@ -5708,6 +5846,7 @@ function runDoctor(store) {
|
|
|
5708
5846
|
|
|
5709
5847
|
// src/lib/health.ts
|
|
5710
5848
|
import { createHash as createHash2 } from "crypto";
|
|
5849
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
|
|
5711
5850
|
var EVIDENCE_CHARS = 2000;
|
|
5712
5851
|
var FINGERPRINT_EVIDENCE_CHARS = 120;
|
|
5713
5852
|
var CLASSIFICATIONS = [
|
|
@@ -5718,6 +5857,7 @@ var CLASSIFICATIONS = [
|
|
|
5718
5857
|
"schema_response_format",
|
|
5719
5858
|
"node_init",
|
|
5720
5859
|
"preflight",
|
|
5860
|
+
"route_functional",
|
|
5721
5861
|
"timeout",
|
|
5722
5862
|
"sigsegv",
|
|
5723
5863
|
"skipped_previous_active",
|
|
@@ -5738,6 +5878,25 @@ function searchableText(run) {
|
|
|
5738
5878
|
return [run.error, run.stderr, run.stdout].filter(Boolean).join(`
|
|
5739
5879
|
`).toLowerCase();
|
|
5740
5880
|
}
|
|
5881
|
+
function isRecord(value) {
|
|
5882
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
5883
|
+
}
|
|
5884
|
+
function stringValue(value) {
|
|
5885
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
5886
|
+
}
|
|
5887
|
+
function objectField(value, key) {
|
|
5888
|
+
if (!value)
|
|
5889
|
+
return;
|
|
5890
|
+
const field = value[key];
|
|
5891
|
+
return isRecord(field) ? field : undefined;
|
|
5892
|
+
}
|
|
5893
|
+
function tagsFromValue(value) {
|
|
5894
|
+
if (Array.isArray(value))
|
|
5895
|
+
return value.map((entry) => String(entry).trim()).filter(Boolean);
|
|
5896
|
+
if (typeof value === "string")
|
|
5897
|
+
return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
5898
|
+
return [];
|
|
5899
|
+
}
|
|
5741
5900
|
function stableFingerprint(parts) {
|
|
5742
5901
|
return createHash2("sha256").update(parts.join(`
|
|
5743
5902
|
`)).digest("hex").slice(0, 16);
|
|
@@ -5751,6 +5910,13 @@ function stableFailureFingerprint(run, classification) {
|
|
|
5751
5910
|
(run.error ?? run.stderr ?? run.stdout ?? "").replace(/\d{4}-\d{2}-\d{2}T\S+/g, "<timestamp>").slice(0, FINGERPRINT_EVIDENCE_CHARS)
|
|
5752
5911
|
]);
|
|
5753
5912
|
}
|
|
5913
|
+
function stableRouteFunctionalFingerprint(loop, reason) {
|
|
5914
|
+
return stableFingerprint([
|
|
5915
|
+
loop.id,
|
|
5916
|
+
"route_functional",
|
|
5917
|
+
reason.replace(/\d{4}-\d{2}-\d{2}T\S+/g, "<timestamp>").slice(0, FINGERPRINT_EVIDENCE_CHARS)
|
|
5918
|
+
]);
|
|
5919
|
+
}
|
|
5754
5920
|
function healthRun(run) {
|
|
5755
5921
|
return {
|
|
5756
5922
|
...run,
|
|
@@ -5795,6 +5961,148 @@ function classifyRunFailure(run) {
|
|
|
5795
5961
|
}
|
|
5796
5962
|
};
|
|
5797
5963
|
}
|
|
5964
|
+
var ROUTE_FUNCTIONAL_DISALLOWED_TAGS = new Set([
|
|
5965
|
+
"no-auto",
|
|
5966
|
+
"manual",
|
|
5967
|
+
"manual-required",
|
|
5968
|
+
"approval-required",
|
|
5969
|
+
"blocked",
|
|
5970
|
+
"completed",
|
|
5971
|
+
"done",
|
|
5972
|
+
"cancelled",
|
|
5973
|
+
"canceled",
|
|
5974
|
+
"failed",
|
|
5975
|
+
"archived"
|
|
5976
|
+
]);
|
|
5977
|
+
var ROUTE_FUNCTIONAL_DISALLOWED_STATUSES = new Set([
|
|
5978
|
+
"blocked",
|
|
5979
|
+
"completed",
|
|
5980
|
+
"done",
|
|
5981
|
+
"cancelled",
|
|
5982
|
+
"canceled",
|
|
5983
|
+
"failed",
|
|
5984
|
+
"archived"
|
|
5985
|
+
]);
|
|
5986
|
+
function parseJsonObject(raw) {
|
|
5987
|
+
if (!raw?.trim())
|
|
5988
|
+
return;
|
|
5989
|
+
try {
|
|
5990
|
+
const parsed = JSON.parse(raw);
|
|
5991
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
5992
|
+
} catch {
|
|
5993
|
+
return;
|
|
5994
|
+
}
|
|
5995
|
+
}
|
|
5996
|
+
function routeEvidenceReport(run) {
|
|
5997
|
+
const stdoutReport = parseJsonObject(run.stdout);
|
|
5998
|
+
const evidencePath = stringValue(stdoutReport?.evidencePath);
|
|
5999
|
+
if (evidencePath && existsSync4(evidencePath)) {
|
|
6000
|
+
try {
|
|
6001
|
+
return parseJsonObject(readFileSync3(evidencePath, "utf8")) ?? stdoutReport;
|
|
6002
|
+
} catch {
|
|
6003
|
+
return stdoutReport;
|
|
6004
|
+
}
|
|
6005
|
+
}
|
|
6006
|
+
return stdoutReport;
|
|
6007
|
+
}
|
|
6008
|
+
function commandName(command) {
|
|
6009
|
+
return command.split(/[\\/]/).at(-1) ?? command;
|
|
6010
|
+
}
|
|
6011
|
+
function argsContainSequence(args, sequence) {
|
|
6012
|
+
for (let index = 0;index <= args.length - sequence.length; index += 1) {
|
|
6013
|
+
if (sequence.every((part, offset) => args[index + offset] === part))
|
|
6014
|
+
return true;
|
|
6015
|
+
}
|
|
6016
|
+
return false;
|
|
6017
|
+
}
|
|
6018
|
+
function isRouteDrainLoop(loop) {
|
|
6019
|
+
if (loop.target.type !== "command")
|
|
6020
|
+
return false;
|
|
6021
|
+
if (commandName(loop.target.command) !== "loops")
|
|
6022
|
+
return false;
|
|
6023
|
+
const args = loop.target.args ?? [];
|
|
6024
|
+
return argsContainSequence(args, ["events", "drain", "todos-task"]) || argsContainSequence(args, ["routes", "drain", "todos-task"]) || argsContainSequence(args, ["route", "drain", "todos-task"]);
|
|
6025
|
+
}
|
|
6026
|
+
function routeResultTaskState(result) {
|
|
6027
|
+
const event = objectField(result, "event");
|
|
6028
|
+
const data = objectField(event, "data");
|
|
6029
|
+
const task = objectField(data, "task");
|
|
6030
|
+
const payload = objectField(data, "payload");
|
|
6031
|
+
const payloadTask = objectField(payload, "task");
|
|
6032
|
+
const metadata = objectField(data, "metadata");
|
|
6033
|
+
const records = [data, task, payload, payloadTask, metadata].filter(isRecord);
|
|
6034
|
+
const tags = new Set;
|
|
6035
|
+
for (const record of records) {
|
|
6036
|
+
for (const tag of tagsFromValue(record.tags ?? record.task_tags ?? record.taskTags)) {
|
|
6037
|
+
tags.add(tag.toLowerCase());
|
|
6038
|
+
}
|
|
6039
|
+
}
|
|
6040
|
+
const status = records.map((record) => stringValue(record.status ?? record.task_status ?? record.taskStatus)?.toLowerCase()).find(Boolean);
|
|
6041
|
+
return {
|
|
6042
|
+
taskId: stringValue(event?.subject) ?? stringValue(data?.id) ?? stringValue(task?.id) ?? stringValue(payloadTask?.id),
|
|
6043
|
+
tags: [...tags],
|
|
6044
|
+
status
|
|
6045
|
+
};
|
|
6046
|
+
}
|
|
6047
|
+
function detectRouteFunctionalFailure(store, loop, run) {
|
|
6048
|
+
if (run.status !== "succeeded")
|
|
6049
|
+
return;
|
|
6050
|
+
if (!isRouteDrainLoop(loop))
|
|
6051
|
+
return;
|
|
6052
|
+
const report = routeEvidenceReport(run);
|
|
6053
|
+
const rawResults = Array.isArray(report?.results) ? report.results.filter(isRecord) : [];
|
|
6054
|
+
for (const result of rawResults) {
|
|
6055
|
+
const kind = stringValue(result.kind);
|
|
6056
|
+
const task = routeResultTaskState(result);
|
|
6057
|
+
const disallowedTag = task.tags.find((tag) => ROUTE_FUNCTIONAL_DISALLOWED_TAGS.has(tag));
|
|
6058
|
+
if (kind && kind !== "skipped" && disallowedTag) {
|
|
6059
|
+
const reason2 = `route drain ${kind} task ${task.taskId ?? "unknown"} with disallowed tag ${disallowedTag}`;
|
|
6060
|
+
return {
|
|
6061
|
+
classification: "route_functional",
|
|
6062
|
+
fingerprint: stableRouteFunctionalFingerprint(loop, reason2),
|
|
6063
|
+
evidence: { error: reason2, stdout: redactedEvidence(run.stdout), exitCode: run.exitCode }
|
|
6064
|
+
};
|
|
6065
|
+
}
|
|
6066
|
+
if (kind && kind !== "skipped" && task.status && ROUTE_FUNCTIONAL_DISALLOWED_STATUSES.has(task.status)) {
|
|
6067
|
+
const reason2 = `route drain ${kind} task ${task.taskId ?? "unknown"} with non-routable status ${task.status}`;
|
|
6068
|
+
return {
|
|
6069
|
+
classification: "route_functional",
|
|
6070
|
+
fingerprint: stableRouteFunctionalFingerprint(loop, reason2),
|
|
6071
|
+
evidence: { error: reason2, stdout: redactedEvidence(run.stdout), exitCode: run.exitCode }
|
|
6072
|
+
};
|
|
6073
|
+
}
|
|
6074
|
+
const reason = stringValue(result.reason);
|
|
6075
|
+
if (kind === "skipped" && reason === "task metadata requires manual or approval-gated handling") {
|
|
6076
|
+
const message = `route drain skipped task ${task.taskId ?? "unknown"} with ambiguous manual-gate reason`;
|
|
6077
|
+
return {
|
|
6078
|
+
classification: "route_functional",
|
|
6079
|
+
fingerprint: stableRouteFunctionalFingerprint(loop, message),
|
|
6080
|
+
evidence: { error: message, stdout: redactedEvidence(run.stdout), exitCode: run.exitCode }
|
|
6081
|
+
};
|
|
6082
|
+
}
|
|
6083
|
+
const sourceTaskUpdate = objectField(result, "sourceTaskUpdate");
|
|
6084
|
+
if (kind === "skipped" && sourceTaskUpdate && sourceTaskUpdate.ok === false) {
|
|
6085
|
+
const updateError = stringValue(sourceTaskUpdate.error);
|
|
6086
|
+
const message = `route drain skipped task ${task.taskId ?? "unknown"} but failed to update source task${updateError ? `: ${updateError}` : ""}`;
|
|
6087
|
+
return {
|
|
6088
|
+
classification: "route_functional",
|
|
6089
|
+
fingerprint: stableRouteFunctionalFingerprint(loop, message),
|
|
6090
|
+
evidence: { error: message, stdout: redactedEvidence(run.stdout), exitCode: run.exitCode }
|
|
6091
|
+
};
|
|
6092
|
+
}
|
|
6093
|
+
const childLoopId = stringValue(objectField(result, "loop")?.id) ?? stringValue(result.loopId);
|
|
6094
|
+
const childRun = childLoopId ? store.listRuns({ loopId: childLoopId, limit: 1 })[0] : undefined;
|
|
6095
|
+
if (childRun && !["succeeded", "running"].includes(childRun.status)) {
|
|
6096
|
+
const message = `route drain ${kind ?? "handled"} task ${task.taskId ?? "unknown"} but child loop ${childLoopId} latest run is ${childRun.status}`;
|
|
6097
|
+
return {
|
|
6098
|
+
classification: "route_functional",
|
|
6099
|
+
fingerprint: stableRouteFunctionalFingerprint(loop, message),
|
|
6100
|
+
evidence: { error: message, stdout: redactedEvidence(run.stdout), stderr: redactedEvidence(childRun.stderr), exitCode: childRun.exitCode }
|
|
6101
|
+
};
|
|
6102
|
+
}
|
|
6103
|
+
}
|
|
6104
|
+
return;
|
|
6105
|
+
}
|
|
5798
6106
|
function targetRoute(loop) {
|
|
5799
6107
|
if (loop.target.type === "agent") {
|
|
5800
6108
|
return {
|
|
@@ -5882,6 +6190,18 @@ function expectationForLoop(store, loop) {
|
|
|
5882
6190
|
route
|
|
5883
6191
|
};
|
|
5884
6192
|
}
|
|
6193
|
+
const routeFailure = detectRouteFunctionalFailure(store, loop, latestRun);
|
|
6194
|
+
if (routeFailure) {
|
|
6195
|
+
return {
|
|
6196
|
+
loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
|
|
6197
|
+
ok: false,
|
|
6198
|
+
check: { id: "route-functional-health", status: "fail", message: routeFailure.evidence.error ?? "route functional blocker detected" },
|
|
6199
|
+
latestRun: healthRun(latestRun),
|
|
6200
|
+
failure: routeFailure,
|
|
6201
|
+
route,
|
|
6202
|
+
recommendedTask: recommendedTask(loop, latestRun, routeFailure, route)
|
|
6203
|
+
};
|
|
6204
|
+
}
|
|
5885
6205
|
if (latestRun.status === "succeeded") {
|
|
5886
6206
|
return {
|
|
5887
6207
|
loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
|
|
@@ -5940,6 +6260,8 @@ var PROVIDER_TOKENS = new Set([
|
|
|
5940
6260
|
"agent"
|
|
5941
6261
|
]);
|
|
5942
6262
|
var REPO_GENERIC_TOKENS = new Set(["repo", "repoops"]);
|
|
6263
|
+
var CADENCE_SUFFIX_TOKENS = new Set(["hourly", "daily", "weekly", "monthly"]);
|
|
6264
|
+
var CADENCE_SUFFIX_PATTERN = /^(?:every-?)?\d+(?:s|m|h|d|w)$/;
|
|
5943
6265
|
function slugify(value) {
|
|
5944
6266
|
return value.normalize("NFKD").replace(/[^\w\s.-]/g, "-").replace(/[_\s.:/]+/g, "-").replace(/[^a-zA-Z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
5945
6267
|
}
|
|
@@ -5992,6 +6314,16 @@ function taskSlug(loop, scope) {
|
|
|
5992
6314
|
if (deduped[deduped.length - 1] !== token)
|
|
5993
6315
|
deduped.push(token);
|
|
5994
6316
|
}
|
|
6317
|
+
while (deduped.length) {
|
|
6318
|
+
const last = deduped[deduped.length - 1];
|
|
6319
|
+
if (CADENCE_SUFFIX_TOKENS.has(last) || CADENCE_SUFFIX_PATTERN.test(last)) {
|
|
6320
|
+
deduped.pop();
|
|
6321
|
+
if (deduped[deduped.length - 1] === "every")
|
|
6322
|
+
deduped.pop();
|
|
6323
|
+
continue;
|
|
6324
|
+
}
|
|
6325
|
+
break;
|
|
6326
|
+
}
|
|
5995
6327
|
return deduped.join("-") || "loop";
|
|
5996
6328
|
}
|
|
5997
6329
|
function canonicalName(loop) {
|
|
@@ -6165,14 +6497,15 @@ function buildScriptInventoryReport(store, opts = {}) {
|
|
|
6165
6497
|
// package.json
|
|
6166
6498
|
var package_default = {
|
|
6167
6499
|
name: "@hasna/loops",
|
|
6168
|
-
version: "0.3.
|
|
6500
|
+
version: "0.3.58",
|
|
6169
6501
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
6170
6502
|
type: "module",
|
|
6171
6503
|
main: "dist/index.js",
|
|
6172
6504
|
types: "dist/index.d.ts",
|
|
6173
6505
|
bin: {
|
|
6174
6506
|
loops: "dist/cli/index.js",
|
|
6175
|
-
"loops-daemon": "dist/daemon/index.js"
|
|
6507
|
+
"loops-daemon": "dist/daemon/index.js",
|
|
6508
|
+
"loops-mcp": "dist/mcp/index.js"
|
|
6176
6509
|
},
|
|
6177
6510
|
exports: {
|
|
6178
6511
|
".": {
|
|
@@ -6183,6 +6516,10 @@ var package_default = {
|
|
|
6183
6516
|
types: "./dist/sdk/index.d.ts",
|
|
6184
6517
|
import: "./dist/sdk/index.js"
|
|
6185
6518
|
},
|
|
6519
|
+
"./mcp": {
|
|
6520
|
+
types: "./dist/mcp/index.d.ts",
|
|
6521
|
+
import: "./dist/mcp/index.js"
|
|
6522
|
+
},
|
|
6186
6523
|
"./storage": {
|
|
6187
6524
|
types: "./dist/lib/store.d.ts",
|
|
6188
6525
|
import: "./dist/lib/store.js"
|
|
@@ -6195,7 +6532,7 @@ var package_default = {
|
|
|
6195
6532
|
"LICENSE"
|
|
6196
6533
|
],
|
|
6197
6534
|
scripts: {
|
|
6198
|
-
build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
|
|
6535
|
+
build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
|
|
6199
6536
|
"build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
|
|
6200
6537
|
typecheck: "tsc --noEmit",
|
|
6201
6538
|
test: "bun test",
|
|
@@ -6231,6 +6568,7 @@ var package_default = {
|
|
|
6231
6568
|
dependencies: {
|
|
6232
6569
|
"@hasna/events": "^0.1.9",
|
|
6233
6570
|
"@hasna/machines": "0.0.49",
|
|
6571
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
6234
6572
|
"@openrouter/ai-sdk-provider": "2.9.1",
|
|
6235
6573
|
ai: "6.0.204",
|
|
6236
6574
|
commander: "^13.1.0",
|
|
@@ -6255,7 +6593,7 @@ function packageVersion() {
|
|
|
6255
6593
|
|
|
6256
6594
|
// src/lib/templates.ts
|
|
6257
6595
|
import { execFileSync } from "child_process";
|
|
6258
|
-
import { existsSync as
|
|
6596
|
+
import { existsSync as existsSync5, lstatSync, mkdirSync as mkdirSync6, readdirSync, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
6259
6597
|
import { homedir as homedir3 } from "os";
|
|
6260
6598
|
import { basename as basename3, isAbsolute as isAbsolute2, join as join5, relative, resolve as resolve2 } from "path";
|
|
6261
6599
|
var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
@@ -6296,7 +6634,8 @@ var TEMPLATE_SUMMARIES = [
|
|
|
6296
6634
|
{ name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
|
|
6297
6635
|
{ name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
|
|
6298
6636
|
{ name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated task/event worktree branches." },
|
|
6299
|
-
{ name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
|
|
6637
|
+
{ name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." },
|
|
6638
|
+
{ name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
|
|
6300
6639
|
]
|
|
6301
6640
|
},
|
|
6302
6641
|
{
|
|
@@ -6327,7 +6666,8 @@ var TEMPLATE_SUMMARIES = [
|
|
|
6327
6666
|
{ name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
|
|
6328
6667
|
{ name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
|
|
6329
6668
|
{ name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated event worktree branches." },
|
|
6330
|
-
{ name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
|
|
6669
|
+
{ name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." },
|
|
6670
|
+
{ name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
|
|
6331
6671
|
]
|
|
6332
6672
|
},
|
|
6333
6673
|
{
|
|
@@ -6355,7 +6695,8 @@ var TEMPLATE_SUMMARIES = [
|
|
|
6355
6695
|
{ name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
|
|
6356
6696
|
{ name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
|
|
6357
6697
|
{ name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated bounded-agent worktree branches." },
|
|
6358
|
-
{ name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
|
|
6698
|
+
{ name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." },
|
|
6699
|
+
{ name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
|
|
6359
6700
|
]
|
|
6360
6701
|
},
|
|
6361
6702
|
{
|
|
@@ -6374,8 +6715,10 @@ var TEMPLATE_SUMMARIES = [
|
|
|
6374
6715
|
{ name: "accountPool", description: "Comma-separated OpenAccounts profiles for non-Codewith providers." },
|
|
6375
6716
|
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
6376
6717
|
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
6718
|
+
{ name: "prHandoff", default: "false", description: "Add a bounded network-enabled PR handoff task step after the worker." },
|
|
6377
6719
|
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
|
|
6378
|
-
{ name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." }
|
|
6720
|
+
{ name: "timeoutMs", default: "unlimited", description: "Agent step timeout in milliseconds, or unlimited/none/null for no timeout. Deterministic helper steps remain bounded." },
|
|
6721
|
+
{ name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
|
|
6379
6722
|
]
|
|
6380
6723
|
},
|
|
6381
6724
|
{
|
|
@@ -6390,7 +6733,8 @@ var TEMPLATE_SUMMARIES = [
|
|
|
6390
6733
|
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
6391
6734
|
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
6392
6735
|
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
6393
|
-
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
|
|
6736
|
+
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
|
|
6737
|
+
{ name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
|
|
6394
6738
|
]
|
|
6395
6739
|
},
|
|
6396
6740
|
{
|
|
@@ -6404,7 +6748,8 @@ var TEMPLATE_SUMMARIES = [
|
|
|
6404
6748
|
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
6405
6749
|
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
6406
6750
|
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
6407
|
-
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
|
|
6751
|
+
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
|
|
6752
|
+
{ name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
|
|
6408
6753
|
]
|
|
6409
6754
|
},
|
|
6410
6755
|
{
|
|
@@ -6418,7 +6763,8 @@ var TEMPLATE_SUMMARIES = [
|
|
|
6418
6763
|
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
6419
6764
|
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
6420
6765
|
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
6421
|
-
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
|
|
6766
|
+
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
|
|
6767
|
+
{ name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
|
|
6422
6768
|
]
|
|
6423
6769
|
},
|
|
6424
6770
|
{
|
|
@@ -6432,7 +6778,8 @@ var TEMPLATE_SUMMARIES = [
|
|
|
6432
6778
|
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
6433
6779
|
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
6434
6780
|
{ name: "sandbox", default: "read-only", description: "Provider sandbox mode." },
|
|
6435
|
-
{ name: "worktreeMode", default: "main", description: "Report-only workflows normally inspect the main checkout read-only." }
|
|
6781
|
+
{ name: "worktreeMode", default: "main", description: "Report-only workflows normally inspect the main checkout read-only." },
|
|
6782
|
+
{ name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
|
|
6436
6783
|
]
|
|
6437
6784
|
},
|
|
6438
6785
|
{
|
|
@@ -6447,7 +6794,8 @@ var TEMPLATE_SUMMARIES = [
|
|
|
6447
6794
|
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
6448
6795
|
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
6449
6796
|
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
6450
|
-
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
|
|
6797
|
+
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." },
|
|
6798
|
+
{ name: "verifierIdleTimeoutMs", default: "900000", description: "Verifier idle watchdog in milliseconds; use none/off to disable when an external heartbeat exists." }
|
|
6451
6799
|
]
|
|
6452
6800
|
},
|
|
6453
6801
|
{
|
|
@@ -6481,9 +6829,25 @@ function taskLabel(input) {
|
|
|
6481
6829
|
return head.length > 160 ? `${head.slice(0, 157)}...` : head;
|
|
6482
6830
|
}
|
|
6483
6831
|
var UNLIMITED_AGENT_TIMEOUT_MS = null;
|
|
6832
|
+
var DEFAULT_VERIFIER_IDLE_TIMEOUT_MS = 15 * 60000;
|
|
6484
6833
|
function agentTimeoutMs(input) {
|
|
6485
6834
|
return input.timeoutMs === undefined ? UNLIMITED_AGENT_TIMEOUT_MS : input.timeoutMs;
|
|
6486
6835
|
}
|
|
6836
|
+
function verifierIdleTimeoutMs(input) {
|
|
6837
|
+
if (input.verifierIdleTimeoutMs === undefined)
|
|
6838
|
+
return DEFAULT_VERIFIER_IDLE_TIMEOUT_MS;
|
|
6839
|
+
return input.verifierIdleTimeoutMs > 0 ? input.verifierIdleTimeoutMs : undefined;
|
|
6840
|
+
}
|
|
6841
|
+
function verifierRuntimeGuidance(input) {
|
|
6842
|
+
const idleTimeout = verifierIdleTimeoutMs(input);
|
|
6843
|
+
return [
|
|
6844
|
+
"Verifier runtime contract:",
|
|
6845
|
+
idleTimeout ? `- OpenLoops will mark this verifier timed_out after ${idleTimeout}ms without stdout/stderr. Emit a concise heartbeat/progress line before long checks.` : "- The verifier idle watchdog is disabled for this workflow; still emit concise progress before long checks.",
|
|
6846
|
+
"- Keep final evidence compact: summarize changed files, validation commands/results, findings, and the task decision instead of pasting bulky logs.",
|
|
6847
|
+
"- If validation cannot finish, record a clear blocked/failed task comment with the last completed check and the next concrete action."
|
|
6848
|
+
].join(`
|
|
6849
|
+
`);
|
|
6850
|
+
}
|
|
6487
6851
|
function parseTemplateTimeoutMs(raw) {
|
|
6488
6852
|
if (raw === undefined || raw.trim() === "")
|
|
6489
6853
|
return;
|
|
@@ -6496,6 +6860,18 @@ function parseTemplateTimeoutMs(raw) {
|
|
|
6496
6860
|
}
|
|
6497
6861
|
return value;
|
|
6498
6862
|
}
|
|
6863
|
+
function parseTemplateIdleTimeoutMs(raw) {
|
|
6864
|
+
if (raw === undefined || raw.trim() === "")
|
|
6865
|
+
return;
|
|
6866
|
+
const normalized = raw.trim().toLowerCase();
|
|
6867
|
+
if (["unlimited", "none", "null", "never", "off", "false"].includes(normalized))
|
|
6868
|
+
return 0;
|
|
6869
|
+
const value = Number(raw);
|
|
6870
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
6871
|
+
throw new Error("verifierIdleTimeoutMs must be a positive integer number of milliseconds, or none/off");
|
|
6872
|
+
}
|
|
6873
|
+
return value;
|
|
6874
|
+
}
|
|
6499
6875
|
function parseDeterministicTimeoutMs(raw, fallbackMs, label = "timeoutMs") {
|
|
6500
6876
|
if (raw === undefined || raw.trim() === "")
|
|
6501
6877
|
return fallbackMs;
|
|
@@ -6556,6 +6932,179 @@ function stableHex(seed) {
|
|
|
6556
6932
|
function shellQuote2(value) {
|
|
6557
6933
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
6558
6934
|
}
|
|
6935
|
+
function prHandoffArtifactPath(plan, taskId) {
|
|
6936
|
+
return join5(plan.cwd, ".openloops", "pr-handoff", `${slugSegment(taskId, "task")}.json`);
|
|
6937
|
+
}
|
|
6938
|
+
function prHandoffCommand(input, plan, todosProjectPath) {
|
|
6939
|
+
const artifactPath = prHandoffArtifactPath(plan, input.taskId);
|
|
6940
|
+
return [
|
|
6941
|
+
"set -euo pipefail",
|
|
6942
|
+
`export OPENLOOPS_PR_HANDOFF_ARTIFACT=${shellQuote2(artifactPath)}`,
|
|
6943
|
+
`export OPENLOOPS_PR_HANDOFF_TASK_ID=${shellQuote2(input.taskId)}`,
|
|
6944
|
+
`export OPENLOOPS_PR_HANDOFF_TODOS_PROJECT=${shellQuote2(todosProjectPath)}`,
|
|
6945
|
+
`export OPENLOOPS_PR_HANDOFF_WORKTREE=${shellQuote2(plan.cwd)}`,
|
|
6946
|
+
`export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote2(plan.path ?? plan.cwd)}`,
|
|
6947
|
+
`export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote2(plan.branch ?? "")}`,
|
|
6948
|
+
'if [ ! -s "$OPENLOOPS_PR_HANDOFF_ARTIFACT" ]; then',
|
|
6949
|
+
` printf 'no PR handoff artifact at %s\\n' "$OPENLOOPS_PR_HANDOFF_ARTIFACT"`,
|
|
6950
|
+
" exit 0",
|
|
6951
|
+
"fi",
|
|
6952
|
+
"bun - <<'BUN'",
|
|
6953
|
+
"const { readFileSync, realpathSync } = await import('node:fs');",
|
|
6954
|
+
"const { spawnSync } = await import('node:child_process');",
|
|
6955
|
+
"const artifactPath = process.env.OPENLOOPS_PR_HANDOFF_ARTIFACT || '';",
|
|
6956
|
+
"const taskId = process.env.OPENLOOPS_PR_HANDOFF_TASK_ID || '';",
|
|
6957
|
+
"const todosProject = process.env.OPENLOOPS_PR_HANDOFF_TODOS_PROJECT || '';",
|
|
6958
|
+
"const fallbackWorktree = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE || process.cwd();",
|
|
6959
|
+
"const expectedRoot = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT || fallbackWorktree;",
|
|
6960
|
+
"const expectedBranch = process.env.OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH || '';",
|
|
6961
|
+
"const todosBin = process.env.OPENLOOPS_PR_HANDOFF_TODOS_BIN || 'todos';",
|
|
6962
|
+
"const gitBin = process.env.OPENLOOPS_PR_HANDOFF_GIT_BIN || 'git';",
|
|
6963
|
+
"const ghBin = process.env.OPENLOOPS_PR_HANDOFF_GH_BIN || 'gh';",
|
|
6964
|
+
"const raw = readFileSync(artifactPath, 'utf8');",
|
|
6965
|
+
"const artifact = JSON.parse(raw);",
|
|
6966
|
+
"const stringField = (...keys) => {",
|
|
6967
|
+
" for (const key of keys) {",
|
|
6968
|
+
" const value = artifact[key];",
|
|
6969
|
+
" if (typeof value === 'string' && value.trim()) return value.trim();",
|
|
6970
|
+
" }",
|
|
6971
|
+
" return undefined;",
|
|
6972
|
+
"};",
|
|
6973
|
+
"const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', ...options });",
|
|
6974
|
+
"const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
|
|
6975
|
+
"const todos = (...args) => run(todosBin, todosArgs(...args));",
|
|
6976
|
+
"const comment = (text) => {",
|
|
6977
|
+
" const result = todos('comment', taskId, text);",
|
|
6978
|
+
" if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
|
|
6979
|
+
"};",
|
|
6980
|
+
"const repoPath = stringField('worktreePath', 'localRepoPath', 'repoPath', 'cwd') || fallbackWorktree;",
|
|
6981
|
+
"const artifactTaskId = stringField('taskId', 'sourceTaskId', 'originalTaskId');",
|
|
6982
|
+
"const branch = stringField('branch', 'headBranch');",
|
|
6983
|
+
"const base = stringField('base', 'baseBranch') || 'main';",
|
|
6984
|
+
"const remote = stringField('remote') || 'origin';",
|
|
6985
|
+
"let commit = stringField('commit', 'commitSha', 'sha');",
|
|
6986
|
+
"const repo = stringField('githubRepo', 'repoSlug', 'repository');",
|
|
6987
|
+
"const prUrl = stringField('prUrl', 'pullRequestUrl');",
|
|
6988
|
+
"const title = stringField('title', 'prTitle') || `PR handoff for ${taskId}`;",
|
|
6989
|
+
"const body = stringField('body', 'prBody') || [",
|
|
6990
|
+
" `OpenLoops PR handoff for task ${taskId}.`,",
|
|
6991
|
+
" `Commit: ${commit || 'unknown'}`,",
|
|
6992
|
+
" `Branch: ${branch || 'unknown'}`,",
|
|
6993
|
+
" artifact.validation ? `Validation: ${artifact.validation}` : undefined,",
|
|
6994
|
+
" artifact.error ? `Worker network error: ${artifact.error}` : undefined,",
|
|
6995
|
+
"].filter(Boolean).join('\\n\\n');",
|
|
6996
|
+
"const fingerprint = stringField('fingerprint') || `openloops:pr-handoff:${taskId}:${branch || 'missing-branch'}:${commit || 'missing-commit'}`;",
|
|
6997
|
+
"const repoTagSource = (repo || stringField('repo', 'remoteUrl') || repoPath).split(/[/:]/).filter(Boolean).at(-1) || 'unknown';",
|
|
6998
|
+
"const repoTag = `repo:${repoTagSource.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown'}`;",
|
|
6999
|
+
"const metadata = {",
|
|
7000
|
+
" route_enabled: true,",
|
|
7001
|
+
" source: 'openloops.pr-handoff',",
|
|
7002
|
+
" original_task_id: taskId,",
|
|
7003
|
+
" repo: repo || stringField('repo', 'remoteUrl') || '',",
|
|
7004
|
+
" branch: branch || '',",
|
|
7005
|
+
" base,",
|
|
7006
|
+
" commit: commit || '',",
|
|
7007
|
+
" artifact_path: artifactPath,",
|
|
7008
|
+
" fingerprint,",
|
|
7009
|
+
" automation: { allowed: true, mode: 'auto' },",
|
|
7010
|
+
" no_tmux_dispatch: true,",
|
|
7011
|
+
"};",
|
|
7012
|
+
"const upsertTask = (why) => {",
|
|
7013
|
+
" const description = [",
|
|
7014
|
+
" `OpenLoops could not complete network PR handoff for original task ${taskId}.`,",
|
|
7015
|
+
" `Reason: ${why}`,",
|
|
7016
|
+
" `Fingerprint: ${fingerprint}`,",
|
|
7017
|
+
" `Repository: ${repo || stringField('repo', 'remoteUrl') || 'unknown'}`,",
|
|
7018
|
+
" `Worktree: ${repoPath}`,",
|
|
7019
|
+
" `Branch: ${branch || 'unknown'}`,",
|
|
7020
|
+
" `Base: ${base}`,",
|
|
7021
|
+
" `Commit: ${commit || 'unknown'}`,",
|
|
7022
|
+
" `Artifact: ${artifactPath}`,",
|
|
7023
|
+
" artifact.validation ? `Validation: ${artifact.validation}` : undefined,",
|
|
7024
|
+
" artifact.error ? `Worker error: ${artifact.error}` : undefined,",
|
|
7025
|
+
" 'Do not rerun implementation work. Push the recorded commit/branch, open or update the PR, then comment the original task with the PR URL and validation evidence.',",
|
|
7026
|
+
" ].filter(Boolean).join('\\n\\n');",
|
|
7027
|
+
" const result = todos(",
|
|
7028
|
+
" 'task',",
|
|
7029
|
+
" 'upsert',",
|
|
7030
|
+
" '--fingerprint', fingerprint,",
|
|
7031
|
+
" '--title', `PR handoff for ${taskId}`,",
|
|
7032
|
+
" '-d', description,",
|
|
7033
|
+
" '-p', 'high',",
|
|
7034
|
+
" '-t', ['auto:route', 'pr-handoff', 'github', 'network', repoTag].join(','),",
|
|
7035
|
+
" '--metadata-json', JSON.stringify(metadata),",
|
|
7036
|
+
" '--working-dir', repoPath,",
|
|
7037
|
+
" );",
|
|
7038
|
+
" if (result.status !== 0) throw new Error(`todos task upsert failed: ${result.stderr || result.stdout || result.status}`);",
|
|
7039
|
+
" comment(`openloops:pr-handoff=pending task=${taskId} artifact=${artifactPath} fingerprint=${fingerprint} reason=${why}`);",
|
|
7040
|
+
" console.log(`queued PR handoff task fingerprint=${fingerprint}`);",
|
|
7041
|
+
"};",
|
|
7042
|
+
"const queueNetworkHandoff = (why) => { upsertTask(why); process.exit(0); };",
|
|
7043
|
+
"const invalidArtifact = (why) => {",
|
|
7044
|
+
" comment(`openloops:pr-handoff=invalid task=${taskId} artifact=${artifactPath} reason=${why}`);",
|
|
7045
|
+
" console.error(`invalid PR handoff artifact: ${why}`);",
|
|
7046
|
+
" process.exit(0);",
|
|
7047
|
+
"};",
|
|
7048
|
+
"const canonicalPath = (path) => {",
|
|
7049
|
+
" try { return realpathSync(path); } catch { return path; }",
|
|
7050
|
+
"};",
|
|
7051
|
+
"if (artifactTaskId && artifactTaskId !== taskId) invalidArtifact(`artifact task id ${artifactTaskId} does not match expected ${taskId}`);",
|
|
7052
|
+
"if (!branch || !commit) invalidArtifact('artifact missing branch or commit');",
|
|
7053
|
+
"const topLevel = run(gitBin, ['-C', repoPath, 'rev-parse', '--show-toplevel']);",
|
|
7054
|
+
"if (topLevel.status !== 0) invalidArtifact(`artifact repoPath is not a git worktree: ${String(topLevel.stderr || topLevel.stdout || topLevel.status).slice(0, 300)}`);",
|
|
7055
|
+
"const actualRoot = canonicalPath(String(topLevel.stdout || '').trim());",
|
|
7056
|
+
"const wantedRoot = canonicalPath(expectedRoot);",
|
|
7057
|
+
"if (actualRoot !== wantedRoot) invalidArtifact(`artifact repo root mismatch: expected ${wantedRoot}, got ${actualRoot}`);",
|
|
7058
|
+
"const currentBranch = run(gitBin, ['-C', repoPath, 'branch', '--show-current']);",
|
|
7059
|
+
"const actualBranch = String(currentBranch.stdout || '').trim();",
|
|
7060
|
+
"if (currentBranch.status !== 0 || !actualBranch) invalidArtifact(`could not resolve current branch for artifact repo: ${String(currentBranch.stderr || currentBranch.stdout || currentBranch.status).slice(0, 300)}`);",
|
|
7061
|
+
"if (expectedBranch && branch !== expectedBranch) invalidArtifact(`artifact branch ${branch} does not match expected ${expectedBranch}`);",
|
|
7062
|
+
"if (branch !== actualBranch) invalidArtifact(`artifact branch ${branch} does not match current worktree branch ${actualBranch}`);",
|
|
7063
|
+
"const resolvedCommit = run(gitBin, ['-C', repoPath, 'rev-parse', '--verify', `${commit}^{commit}`]);",
|
|
7064
|
+
"if (resolvedCommit.status !== 0) invalidArtifact(`artifact commit is not present in repo: ${String(resolvedCommit.stderr || resolvedCommit.stdout || resolvedCommit.status).slice(0, 300)}`);",
|
|
7065
|
+
"commit = String(resolvedCommit.stdout || commit).trim();",
|
|
7066
|
+
"const reachable = run(gitBin, ['-C', repoPath, 'merge-base', '--is-ancestor', commit, 'HEAD']);",
|
|
7067
|
+
"if (reachable.status !== 0) invalidArtifact(`artifact commit ${commit} is not reachable from HEAD`);",
|
|
7068
|
+
"if (prUrl) {",
|
|
7069
|
+
` const viewed = run(ghBin, ['pr', 'view', prUrl, '--json', 'url,headRefName', '--jq', '.url + "\\\\n" + .headRefName']);`,
|
|
7070
|
+
" if (viewed.status !== 0) queueNetworkHandoff(`could not verify existing PR URL: ${String(viewed.stderr || viewed.stdout || viewed.status).slice(0, 300)}`);",
|
|
7071
|
+
" const [verifiedUrl, verifiedHead] = String(viewed.stdout || '').trim().split(/\\r?\\n/);",
|
|
7072
|
+
" if (!verifiedUrl || !/^https?:\\/\\//.test(verifiedUrl)) invalidArtifact('verified PR URL was missing or invalid');",
|
|
7073
|
+
" if (verifiedHead && verifiedHead !== branch) invalidArtifact(`verified PR head ${verifiedHead} does not match artifact branch ${branch}`);",
|
|
7074
|
+
" comment(`openloops:pr-handoff=done task=${taskId} pr=${verifiedUrl} commit=${commit} branch=${branch}`);",
|
|
7075
|
+
" console.log(`PR handoff already complete: ${verifiedUrl}`);",
|
|
7076
|
+
" process.exit(0);",
|
|
7077
|
+
"}",
|
|
7078
|
+
"const push = run(gitBin, ['-C', repoPath, 'push', remote, `${commit}:refs/heads/${branch}`]);",
|
|
7079
|
+
"if (push.status !== 0) {",
|
|
7080
|
+
" upsertTask(`git push failed: ${String(push.stderr || push.stdout || push.status).slice(0, 300)}`);",
|
|
7081
|
+
" process.exit(0);",
|
|
7082
|
+
"}",
|
|
7083
|
+
"const ghRepoArgs = repo ? ['--repo', repo] : [];",
|
|
7084
|
+
"const existing = run(ghBin, ['pr', 'list', ...ghRepoArgs, '--head', branch, '--state', 'all', '--json', 'url', '--jq', '.[0].url']);",
|
|
7085
|
+
"let finalPrUrl = existing.status === 0 ? String(existing.stdout || '').trim() : '';",
|
|
7086
|
+
"if (!finalPrUrl) {",
|
|
7087
|
+
" const created = run(ghBin, ['pr', 'create', ...ghRepoArgs, '--base', base, '--head', branch, '--title', title, '--body', body], { cwd: repoPath });",
|
|
7088
|
+
" if (created.status !== 0) {",
|
|
7089
|
+
" upsertTask(`gh pr create failed: ${String(created.stderr || created.stdout || created.status).slice(0, 300)}`);",
|
|
7090
|
+
" process.exit(0);",
|
|
7091
|
+
" }",
|
|
7092
|
+
" finalPrUrl = String(created.stdout || '').trim().split(/\\r?\\n/).find((line) => /^https?:\\/\\//.test(line)) || String(created.stdout || '').trim();",
|
|
7093
|
+
"}",
|
|
7094
|
+
"comment(`openloops:pr-handoff=done task=${taskId} pr=${finalPrUrl} commit=${commit} branch=${branch}`);",
|
|
7095
|
+
"console.log(`PR handoff complete: ${finalPrUrl}`);",
|
|
7096
|
+
"BUN"
|
|
7097
|
+
].join(`
|
|
7098
|
+
`);
|
|
7099
|
+
}
|
|
7100
|
+
function sourceTaskGateCommand(todosProjectPath, taskId) {
|
|
7101
|
+
return [
|
|
7102
|
+
"set -euo pipefail",
|
|
7103
|
+
`todos --project ${shellQuote2(todosProjectPath)} --json inspect ${shellQuote2(taskId)} >/dev/null`,
|
|
7104
|
+
`printf "source task %s resolved in todos project %s\\n" ${shellQuote2(taskId)} ${shellQuote2(todosProjectPath)}`
|
|
7105
|
+
].join(`
|
|
7106
|
+
`);
|
|
7107
|
+
}
|
|
6559
7108
|
function normalizeWorktreeMode(mode) {
|
|
6560
7109
|
const value = mode ?? "auto";
|
|
6561
7110
|
if (!["auto", "required", "off", "main"].includes(value)) {
|
|
@@ -6571,7 +7120,7 @@ function defaultWorktreeRoot(root) {
|
|
|
6571
7120
|
return join5(homedir3(), ".hasna", "loops", "worktrees");
|
|
6572
7121
|
}
|
|
6573
7122
|
function gitRootFor(path) {
|
|
6574
|
-
if (!
|
|
7123
|
+
if (!existsSync5(path))
|
|
6575
7124
|
return;
|
|
6576
7125
|
try {
|
|
6577
7126
|
return execFileSync("git", ["-C", path, "rev-parse", "--show-toplevel"], {
|
|
@@ -6583,7 +7132,7 @@ function gitRootFor(path) {
|
|
|
6583
7132
|
}
|
|
6584
7133
|
}
|
|
6585
7134
|
function gitCommonDirFor(path) {
|
|
6586
|
-
if (!
|
|
7135
|
+
if (!existsSync5(path))
|
|
6587
7136
|
return;
|
|
6588
7137
|
try {
|
|
6589
7138
|
const raw = execFileSync("git", ["-C", path, "rev-parse", "--git-common-dir"], {
|
|
@@ -6795,7 +7344,8 @@ function agentTarget(input, prompt, role, seed, plan) {
|
|
|
6795
7344
|
...input.projectGroup ? { projectGroup: input.projectGroup } : {}
|
|
6796
7345
|
},
|
|
6797
7346
|
account: accountForRole(input, role, seed),
|
|
6798
|
-
timeoutMs: agentTimeoutMs(input)
|
|
7347
|
+
timeoutMs: agentTimeoutMs(input),
|
|
7348
|
+
idleTimeoutMs: role === "verifier" ? verifierIdleTimeoutMs(input) : undefined
|
|
6799
7349
|
};
|
|
6800
7350
|
}
|
|
6801
7351
|
function workflowStepsWithWorktree(plan, steps) {
|
|
@@ -6975,7 +7525,7 @@ function customTemplateSummary(definition, sourcePath) {
|
|
|
6975
7525
|
function readCustomTemplateFile(file) {
|
|
6976
7526
|
let parsed;
|
|
6977
7527
|
try {
|
|
6978
|
-
parsed = JSON.parse(
|
|
7528
|
+
parsed = JSON.parse(readFileSync4(file, "utf8"));
|
|
6979
7529
|
} catch (error) {
|
|
6980
7530
|
const message = error instanceof Error ? error.message : String(error);
|
|
6981
7531
|
throw new Error(`failed to read custom template ${file}: ${message}`);
|
|
@@ -7001,7 +7551,7 @@ function assertNoTemplateCollisions(entries) {
|
|
|
7001
7551
|
}
|
|
7002
7552
|
function loadCustomLoopTemplatesRaw() {
|
|
7003
7553
|
const dir = customLoopTemplatesDir();
|
|
7004
|
-
if (!
|
|
7554
|
+
if (!existsSync5(dir))
|
|
7005
7555
|
return [];
|
|
7006
7556
|
return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
|
|
7007
7557
|
const file = join5(dir, entry.name);
|
|
@@ -7125,7 +7675,7 @@ function importCustomLoopTemplate(file, opts = {}) {
|
|
|
7125
7675
|
const entry = readCustomTemplateFile(source);
|
|
7126
7676
|
const dir = ensureCustomLoopTemplatesDir();
|
|
7127
7677
|
const destination = join5(dir, `${entry.definition.id}.json`);
|
|
7128
|
-
const replaced =
|
|
7678
|
+
const replaced = existsSync5(destination);
|
|
7129
7679
|
const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve2(template.path) !== resolve2(destination));
|
|
7130
7680
|
assertNoTemplateCollisions([...existing, { ...entry, path: destination, summary: customTemplateSummary(entry.definition, destination) }]);
|
|
7131
7681
|
if (replaced) {
|
|
@@ -7221,6 +7771,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
7221
7771
|
`- Record verification: todos --project ${todosProjectPath} comment ${input.taskId} "<verification evidence or blocker>"`,
|
|
7222
7772
|
`- If valid and complete: todos --project ${todosProjectPath} done ${input.taskId}`,
|
|
7223
7773
|
"Use fresh context. Inspect the task, repository state, commits, tests, and worker evidence. Act as an adversarial reviewer focused on correctness, regressions, missing tests, security, and incomplete requirements.",
|
|
7774
|
+
verifierRuntimeGuidance(input),
|
|
7224
7775
|
"If the work is valid, record verification evidence in todos and mark/leave the task in the correct completed state according to the todos CLI. If it is not valid, add precise follow-up tasks or comments and leave the original task open or blocked with clear evidence.",
|
|
7225
7776
|
"Do not dispatch or paste prompts into tmux panes. If additional work is required, create or update deduped todos tasks so task-created routing can start a fresh headless workflow.",
|
|
7226
7777
|
"Do not make broad unrelated changes. Only apply tiny verification fixes when they are necessary and low risk; otherwise create follow-up tasks.",
|
|
@@ -7233,10 +7784,24 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
7233
7784
|
description: `Task-triggered worker/verifier workflow for ${taskLabel(input)}`,
|
|
7234
7785
|
version: 1,
|
|
7235
7786
|
steps: workflowStepsWithWorktree(plan, [
|
|
7787
|
+
{
|
|
7788
|
+
id: "source-task-gate",
|
|
7789
|
+
name: "Source Task Gate",
|
|
7790
|
+
description: "Fail before worker execution when the source todos task is not resolvable.",
|
|
7791
|
+
target: {
|
|
7792
|
+
type: "command",
|
|
7793
|
+
command: "bash",
|
|
7794
|
+
args: ["-lc", sourceTaskGateCommand(todosProjectPath, input.taskId)],
|
|
7795
|
+
cwd: plan.cwd,
|
|
7796
|
+
timeoutMs: 60000
|
|
7797
|
+
},
|
|
7798
|
+
timeoutMs: 60000
|
|
7799
|
+
},
|
|
7236
7800
|
{
|
|
7237
7801
|
id: "worker",
|
|
7238
7802
|
name: "Worker",
|
|
7239
7803
|
description: "Implement the todos task and record evidence.",
|
|
7804
|
+
dependsOn: ["source-task-gate"],
|
|
7240
7805
|
target: agentTarget(input, workerPrompt, "worker", input.taskId, plan),
|
|
7241
7806
|
timeoutMs: agentTimeoutMs(input)
|
|
7242
7807
|
},
|
|
@@ -7277,6 +7842,14 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
7277
7842
|
reason: plan.reason
|
|
7278
7843
|
}
|
|
7279
7844
|
};
|
|
7845
|
+
const handoffArtifactPath = prHandoffArtifactPath(plan, input.taskId);
|
|
7846
|
+
const prHandoffGuidance = input.prHandoff ? [
|
|
7847
|
+
"PR handoff mode is enabled for this lifecycle.",
|
|
7848
|
+
`If implementation and validation pass but git push or gh PR creation fails because DNS, network, or sandbox policy blocks GitHub access, write a JSON artifact to: ${handoffArtifactPath}`,
|
|
7849
|
+
"The artifact must include taskId, worktreePath or repoPath, branch, base, commit, remote, validation, and error. Include githubRepo, title, and body when known.",
|
|
7850
|
+
"After writing the artifact, comment the source task with the artifact path and exit without marking the task done. The bounded PR handoff step will push/open the PR or queue a network-enabled handoff task without rerunning implementation."
|
|
7851
|
+
].join(`
|
|
7852
|
+
`) : "";
|
|
7280
7853
|
const shared = [
|
|
7281
7854
|
worktreePrompt(plan),
|
|
7282
7855
|
`Todos project path: ${todosProjectPath}`,
|
|
@@ -7286,7 +7859,8 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
7286
7859
|
"Do not dispatch or paste prompts into tmux panes. If additional work is required, create or update deduped todos tasks so task-created routing can start a fresh headless workflow.",
|
|
7287
7860
|
"Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
|
|
7288
7861
|
"",
|
|
7289
|
-
`Task context JSON: ${compactJson(taskContext)}
|
|
7862
|
+
`Task context JSON: ${compactJson(taskContext)}`,
|
|
7863
|
+
prHandoffGuidance
|
|
7290
7864
|
].join(`
|
|
7291
7865
|
`);
|
|
7292
7866
|
const gateMarker = (stage, state) => `openloops:${stage}=${state} task=${input.taskId}${input.eventId ? ` event=${input.eventId}` : ""}`;
|
|
@@ -7330,7 +7904,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
7330
7904
|
"const latestMarker = markers.at(-1)?.state;",
|
|
7331
7905
|
"const blockers = [];",
|
|
7332
7906
|
"if (blockedStatuses.has(status)) blockers.push(`task status is ${status}`);",
|
|
7333
|
-
"for (const tag of ['no-auto', 'manual', 'manual-required', 'approval-required']) {",
|
|
7907
|
+
"for (const tag of ['no-auto', 'manual', 'manual-required', 'approval-required', 'blocked', 'completed', 'done', 'cancelled', 'canceled', 'failed', 'archived']) {",
|
|
7334
7908
|
" if (tags.has(tag)) blockers.push(`task has disallowed tag ${tag}`);",
|
|
7335
7909
|
"}",
|
|
7336
7910
|
"for (const [key, source] of records.entries()) {",
|
|
@@ -7359,7 +7933,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
7359
7933
|
"Include the triage decision, duplicates/dependencies found, and any follow-up tasks created in that same comment.",
|
|
7360
7934
|
`If the task should not proceed automatically, run: todos --project ${todosProjectPath} update ${input.taskId} --status blocked`,
|
|
7361
7935
|
`Then add a task comment whose first line is exactly: ${gateMarker("triage", "blocked")}`,
|
|
7362
|
-
"The deterministic triage gate will stop later steps unless the latest triage marker is the exact go marker and the task has no blocked/no-auto/manual/approval-required state."
|
|
7936
|
+
"The deterministic triage gate will stop later steps unless the latest triage marker is the exact go marker and the task has no blocked/completed/done/cancelled/failed/archived/no-auto/manual/approval-required state."
|
|
7363
7937
|
].join(`
|
|
7364
7938
|
`);
|
|
7365
7939
|
const plannerPrompt = [
|
|
@@ -7372,7 +7946,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
7372
7946
|
"In that same comment, include a concise implementation plan: files/areas to inspect, validation commands, risk checks, expected commit/PR behavior, and any cross-repo tasks that should be created separately.",
|
|
7373
7947
|
`Do not implement repo changes in this step. If the task is too broad or unsafe for automation, run: todos --project ${todosProjectPath} update ${input.taskId} --status blocked`,
|
|
7374
7948
|
`Then add a task comment whose first line is exactly: ${gateMarker("planner", "blocked")}`,
|
|
7375
|
-
"Create smaller deduped tasks and record evidence. The deterministic planner gate will stop the worker unless the latest planner marker is the exact go marker and the task has no blocked/no-auto/manual/approval-required state."
|
|
7949
|
+
"Create smaller deduped tasks and record evidence. The deterministic planner gate will stop the worker unless the latest planner marker is the exact go marker and the task has no blocked/completed/done/cancelled/failed/archived/no-auto/manual/approval-required state."
|
|
7376
7950
|
].join(`
|
|
7377
7951
|
`);
|
|
7378
7952
|
const workerPrompt = [
|
|
@@ -7382,8 +7956,9 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
7382
7956
|
shared,
|
|
7383
7957
|
`- Claim/start if appropriate: todos --project ${todosProjectPath} start ${input.taskId}`,
|
|
7384
7958
|
"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.",
|
|
7959
|
+
input.prHandoff ? `When only GitHub network access is blocked after a successful commit/validation, record the handoff artifact at ${handoffArtifactPath} instead of repeatedly retrying push/PR creation.` : undefined,
|
|
7385
7960
|
"Do not mark the task complete in the worker step; the verifier step owns completion after independent validation."
|
|
7386
|
-
].join(`
|
|
7961
|
+
].filter(Boolean).join(`
|
|
7387
7962
|
`);
|
|
7388
7963
|
const verifierPrompt = [
|
|
7389
7964
|
`/goal Verify todos task ${input.taskId} after the full lifecycle worker step.`,
|
|
@@ -7393,75 +7968,108 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
7393
7968
|
`- Record verification: todos --project ${todosProjectPath} comment ${input.taskId} "<verification evidence or blocker>"`,
|
|
7394
7969
|
`- If valid and complete: todos --project ${todosProjectPath} done ${input.taskId}`,
|
|
7395
7970
|
"Use fresh context. Inspect triage, plan, worker evidence, repo state, commits, tests, and acceptance criteria. Act as an adversarial reviewer focused on correctness, regressions, missing tests, security, and incomplete requirements.",
|
|
7971
|
+
verifierRuntimeGuidance(input),
|
|
7972
|
+
input.prHandoff ? `If ${handoffArtifactPath} exists and there is no PR URL evidence, verify that the PR handoff step queued or completed a bounded handoff; leave the original task open or blocked until PR evidence is recorded.` : undefined,
|
|
7396
7973
|
"If the work is valid, record verification evidence in todos and mark/leave the task completed according to the todos CLI. If not valid, add precise follow-up tasks or comments and leave the original task open or blocked with clear evidence.",
|
|
7397
7974
|
"Do not make broad unrelated changes. Only apply tiny verification fixes when they are necessary and low risk; otherwise create follow-up tasks."
|
|
7398
|
-
].join(`
|
|
7975
|
+
].filter(Boolean).join(`
|
|
7399
7976
|
`);
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
{
|
|
7406
|
-
|
|
7407
|
-
|
|
7408
|
-
|
|
7409
|
-
|
|
7410
|
-
timeoutMs:
|
|
7977
|
+
const steps = [
|
|
7978
|
+
{
|
|
7979
|
+
id: "source-task-gate",
|
|
7980
|
+
name: "Source Task Gate",
|
|
7981
|
+
description: "Fail before lifecycle agents execute when the source todos task is not resolvable.",
|
|
7982
|
+
target: {
|
|
7983
|
+
type: "command",
|
|
7984
|
+
command: "bash",
|
|
7985
|
+
args: ["-lc", sourceTaskGateCommand(todosProjectPath, input.taskId)],
|
|
7986
|
+
cwd: plan.cwd,
|
|
7987
|
+
timeoutMs: 60000
|
|
7411
7988
|
},
|
|
7412
|
-
|
|
7413
|
-
|
|
7414
|
-
|
|
7415
|
-
|
|
7416
|
-
|
|
7417
|
-
|
|
7418
|
-
|
|
7419
|
-
|
|
7420
|
-
|
|
7421
|
-
|
|
7422
|
-
|
|
7423
|
-
|
|
7989
|
+
timeoutMs: 60000
|
|
7990
|
+
},
|
|
7991
|
+
{
|
|
7992
|
+
id: "triage",
|
|
7993
|
+
name: "Triage",
|
|
7994
|
+
description: "Check task eligibility, duplicates, dependencies, and automation gates.",
|
|
7995
|
+
dependsOn: ["source-task-gate"],
|
|
7996
|
+
target: agentTarget(input, triagePrompt, "triage", input.taskId, plan),
|
|
7997
|
+
timeoutMs: agentTimeoutMs(input)
|
|
7998
|
+
},
|
|
7999
|
+
{
|
|
8000
|
+
id: "triage-gate",
|
|
8001
|
+
name: "Triage Gate",
|
|
8002
|
+
description: "Stop the lifecycle before planning when triage blocked or disallowed automation.",
|
|
8003
|
+
dependsOn: ["triage"],
|
|
8004
|
+
target: {
|
|
8005
|
+
type: "command",
|
|
8006
|
+
command: "bash",
|
|
8007
|
+
args: ["-lc", gateCommand("triage")],
|
|
8008
|
+
cwd: plan.cwd,
|
|
7424
8009
|
timeoutMs: 2 * 60000
|
|
7425
8010
|
},
|
|
7426
|
-
|
|
7427
|
-
|
|
7428
|
-
|
|
7429
|
-
|
|
7430
|
-
|
|
7431
|
-
|
|
7432
|
-
|
|
7433
|
-
|
|
7434
|
-
|
|
7435
|
-
|
|
7436
|
-
|
|
7437
|
-
|
|
7438
|
-
|
|
7439
|
-
|
|
7440
|
-
|
|
7441
|
-
|
|
7442
|
-
|
|
7443
|
-
|
|
7444
|
-
|
|
7445
|
-
|
|
8011
|
+
timeoutMs: 2 * 60000
|
|
8012
|
+
},
|
|
8013
|
+
{
|
|
8014
|
+
id: "planner",
|
|
8015
|
+
name: "Planner",
|
|
8016
|
+
description: "Create a concise implementation plan and split unsafe scope before work starts.",
|
|
8017
|
+
dependsOn: ["triage-gate"],
|
|
8018
|
+
target: agentTarget(input, plannerPrompt, "planner", input.taskId, plan),
|
|
8019
|
+
timeoutMs: agentTimeoutMs(input)
|
|
8020
|
+
},
|
|
8021
|
+
{
|
|
8022
|
+
id: "planner-gate",
|
|
8023
|
+
name: "Planner Gate",
|
|
8024
|
+
description: "Stop the lifecycle before implementation when planning blocked or disallowed automation.",
|
|
8025
|
+
dependsOn: ["planner"],
|
|
8026
|
+
target: {
|
|
8027
|
+
type: "command",
|
|
8028
|
+
command: "bash",
|
|
8029
|
+
args: ["-lc", gateCommand("planner")],
|
|
8030
|
+
cwd: plan.cwd,
|
|
7446
8031
|
timeoutMs: 2 * 60000
|
|
7447
8032
|
},
|
|
7448
|
-
|
|
7449
|
-
|
|
7450
|
-
|
|
7451
|
-
|
|
7452
|
-
|
|
7453
|
-
|
|
7454
|
-
|
|
8033
|
+
timeoutMs: 2 * 60000
|
|
8034
|
+
},
|
|
8035
|
+
{
|
|
8036
|
+
id: "worker",
|
|
8037
|
+
name: "Worker",
|
|
8038
|
+
description: "Implement the todos task according to triage and planner evidence.",
|
|
8039
|
+
dependsOn: ["planner-gate"],
|
|
8040
|
+
target: agentTarget(input, workerPrompt, "worker", input.taskId, plan),
|
|
8041
|
+
timeoutMs: agentTimeoutMs(input)
|
|
8042
|
+
}
|
|
8043
|
+
];
|
|
8044
|
+
if (input.prHandoff) {
|
|
8045
|
+
steps.push({
|
|
8046
|
+
id: "pr-handoff",
|
|
8047
|
+
name: "PR Handoff",
|
|
8048
|
+
description: "Push/open a PR from a worker handoff artifact or queue a bounded network handoff task.",
|
|
8049
|
+
dependsOn: ["worker"],
|
|
8050
|
+
target: {
|
|
8051
|
+
type: "command",
|
|
8052
|
+
command: "bash",
|
|
8053
|
+
args: ["-lc", prHandoffCommand(input, plan, todosProjectPath)],
|
|
8054
|
+
cwd: plan.cwd,
|
|
8055
|
+
timeoutMs: 2 * 60000
|
|
7455
8056
|
},
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
|
|
7461
|
-
|
|
7462
|
-
|
|
7463
|
-
|
|
7464
|
-
|
|
8057
|
+
timeoutMs: 2 * 60000
|
|
8058
|
+
});
|
|
8059
|
+
}
|
|
8060
|
+
steps.push({
|
|
8061
|
+
id: "verifier",
|
|
8062
|
+
name: "Verifier",
|
|
8063
|
+
description: "Adversarially verify worker output and update todos.",
|
|
8064
|
+
dependsOn: [input.prHandoff ? "pr-handoff" : "worker"],
|
|
8065
|
+
target: agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
|
|
8066
|
+
timeoutMs: agentTimeoutMs(input)
|
|
8067
|
+
});
|
|
8068
|
+
return {
|
|
8069
|
+
name: `task-lifecycle-${input.taskId.slice(0, 8)}-triage-plan-worker-verifier`,
|
|
8070
|
+
description: `Full task lifecycle workflow for ${taskLabel(input)}`,
|
|
8071
|
+
version: 1,
|
|
8072
|
+
steps: workflowStepsWithWorktree(plan, steps)
|
|
7465
8073
|
};
|
|
7466
8074
|
}
|
|
7467
8075
|
function renderEventWorkerVerifierWorkflow(input) {
|
|
@@ -7513,6 +8121,7 @@ function renderEventWorkerVerifierWorkflow(input) {
|
|
|
7513
8121
|
"You are the verifier agent for an event-triggered OpenLoops workflow.",
|
|
7514
8122
|
worktreePrompt(plan),
|
|
7515
8123
|
"Use fresh context. Inspect the event, repository/project state, worker evidence, tests, and any created tasks or notes. Act as an adversarial reviewer focused on correctness, regressions, security, missing evidence, and incomplete requirements.",
|
|
8124
|
+
verifierRuntimeGuidance(input),
|
|
7516
8125
|
"If the work is valid, record verification evidence in the relevant local system. If it is not valid, add precise follow-up tasks/comments and leave the event handling state open or blocked with clear evidence.",
|
|
7517
8126
|
"",
|
|
7518
8127
|
`Event context JSON: ${compactJson(eventContext)}`,
|
|
@@ -7567,6 +8176,7 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
|
|
|
7567
8176
|
"You are the verifier step for a bounded OpenLoops agent workflow.",
|
|
7568
8177
|
worktreePrompt(plan),
|
|
7569
8178
|
"Use fresh context. Review the worker result for correctness, regressions, missing tests, safety, runaway-agent risk, output bounds, and incomplete evidence.",
|
|
8179
|
+
verifierRuntimeGuidance(input),
|
|
7570
8180
|
"If valid, record verification evidence. If invalid, create precise follow-up tasks or comments and leave the original work open. Do not make broad unrelated changes."
|
|
7571
8181
|
].join(`
|
|
7572
8182
|
`);
|
|
@@ -7617,7 +8227,8 @@ function renderLifecycleBoundedTemplate(id, values) {
|
|
|
7617
8227
|
worktreeMode: values.worktreeMode ?? (id === REPORT_ONLY_TEMPLATE_ID ? "main" : "required"),
|
|
7618
8228
|
worktreeRoot: values.worktreeRoot,
|
|
7619
8229
|
worktreeBranchPrefix: values.worktreeBranchPrefix,
|
|
7620
|
-
timeoutMs: parseTemplateTimeoutMs(values.timeoutMs)
|
|
8230
|
+
timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
|
|
8231
|
+
verifierIdleTimeoutMs: parseTemplateIdleTimeoutMs(values.verifierIdleTimeoutMs ?? values.verifierIdleTimeout)
|
|
7621
8232
|
};
|
|
7622
8233
|
if (id === TASK_LIFECYCLE_TEMPLATE_ID) {
|
|
7623
8234
|
const taskId = values.taskId ?? "";
|
|
@@ -7651,10 +8262,12 @@ function renderLifecycleBoundedTemplate(id, values) {
|
|
|
7651
8262
|
permissionMode: values.permissionMode,
|
|
7652
8263
|
sandbox: values.sandbox,
|
|
7653
8264
|
manualBreakGlass: booleanVar(values.manualBreakGlass),
|
|
8265
|
+
prHandoff: booleanVar(values.prHandoff),
|
|
7654
8266
|
worktreeMode: values.worktreeMode ?? "required",
|
|
7655
8267
|
worktreeRoot: values.worktreeRoot,
|
|
7656
8268
|
worktreeBranchPrefix: values.worktreeBranchPrefix,
|
|
7657
8269
|
timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
|
|
8270
|
+
verifierIdleTimeoutMs: parseTemplateIdleTimeoutMs(values.verifierIdleTimeoutMs ?? values.verifierIdleTimeout),
|
|
7658
8271
|
eventId: values.eventId,
|
|
7659
8272
|
eventType: values.eventType
|
|
7660
8273
|
});
|
|
@@ -7779,6 +8392,7 @@ function renderBuiltinLoopTemplate(id, values) {
|
|
|
7779
8392
|
worktreeRoot: values.worktreeRoot,
|
|
7780
8393
|
worktreeBranchPrefix: values.worktreeBranchPrefix,
|
|
7781
8394
|
timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
|
|
8395
|
+
verifierIdleTimeoutMs: parseTemplateIdleTimeoutMs(values.verifierIdleTimeoutMs ?? values.verifierIdleTimeout),
|
|
7782
8396
|
eventId: values.eventId,
|
|
7783
8397
|
eventType: values.eventType
|
|
7784
8398
|
});
|
|
@@ -7811,7 +8425,8 @@ function renderBuiltinLoopTemplate(id, values) {
|
|
|
7811
8425
|
worktreeMode: values.worktreeMode,
|
|
7812
8426
|
worktreeRoot: values.worktreeRoot,
|
|
7813
8427
|
worktreeBranchPrefix: values.worktreeBranchPrefix,
|
|
7814
|
-
timeoutMs: parseTemplateTimeoutMs(values.timeoutMs)
|
|
8428
|
+
timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
|
|
8429
|
+
verifierIdleTimeoutMs: parseTemplateIdleTimeoutMs(values.verifierIdleTimeoutMs ?? values.verifierIdleTimeout)
|
|
7815
8430
|
});
|
|
7816
8431
|
}
|
|
7817
8432
|
if (id === BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID) {
|
|
@@ -7839,7 +8454,8 @@ function renderBuiltinLoopTemplate(id, values) {
|
|
|
7839
8454
|
worktreeMode: values.worktreeMode,
|
|
7840
8455
|
worktreeRoot: values.worktreeRoot,
|
|
7841
8456
|
worktreeBranchPrefix: values.worktreeBranchPrefix,
|
|
7842
|
-
timeoutMs: parseTemplateTimeoutMs(values.timeoutMs)
|
|
8457
|
+
timeoutMs: parseTemplateTimeoutMs(values.timeoutMs),
|
|
8458
|
+
verifierIdleTimeoutMs: parseTemplateIdleTimeoutMs(values.verifierIdleTimeoutMs ?? values.verifierIdleTimeout)
|
|
7843
8459
|
});
|
|
7844
8460
|
}
|
|
7845
8461
|
throw new Error(`unknown template: ${id}`);
|
|
@@ -7946,7 +8562,7 @@ function normalizeWorkflowForStorage(body, context) {
|
|
|
7946
8562
|
function workflowBodyFromFile(file, fallbackName, context) {
|
|
7947
8563
|
try {
|
|
7948
8564
|
const resolved = resolve3(file);
|
|
7949
|
-
return workflowBodyFromJson(JSON.parse(
|
|
8565
|
+
return workflowBodyFromJson(JSON.parse(readFileSync5(resolved, "utf8")), fallbackName, { baseDir: dirname4(resolved) });
|
|
7950
8566
|
} catch (error) {
|
|
7951
8567
|
validationFailed(error, context);
|
|
7952
8568
|
}
|
|
@@ -8021,6 +8637,21 @@ function workflowTimeoutMigrationName(workflow, timeoutMs) {
|
|
|
8021
8637
|
const suffix = randomUUID().replace(/-/g, "").slice(0, 8);
|
|
8022
8638
|
return `${workflow.name}-${policy}-${suffix}`;
|
|
8023
8639
|
}
|
|
8640
|
+
function workflowWithoutGoalWrapper(workflow, opts = {}) {
|
|
8641
|
+
return {
|
|
8642
|
+
body: {
|
|
8643
|
+
name: opts.name ?? workflow.name,
|
|
8644
|
+
description: workflow.description,
|
|
8645
|
+
version: workflow.version,
|
|
8646
|
+
steps: workflow.steps
|
|
8647
|
+
},
|
|
8648
|
+
changed: workflow.goal !== undefined
|
|
8649
|
+
};
|
|
8650
|
+
}
|
|
8651
|
+
function workflowGoalWrapperMigrationName(workflow) {
|
|
8652
|
+
const suffix = randomUUID().replace(/-/g, "").slice(0, 8);
|
|
8653
|
+
return `${workflow.name}-no-workflow-goal-${suffix}`;
|
|
8654
|
+
}
|
|
8024
8655
|
function printTextOutput(value) {
|
|
8025
8656
|
for (const line of textOutputBlocks(value, { indent: " " }))
|
|
8026
8657
|
console.log(line);
|
|
@@ -8072,6 +8703,10 @@ function timeoutDuration(raw, label) {
|
|
|
8072
8703
|
throw new Error(`${label} must be a duration greater than zero, or none/unlimited`);
|
|
8073
8704
|
return value;
|
|
8074
8705
|
}
|
|
8706
|
+
function idleTimeoutDuration(raw, label) {
|
|
8707
|
+
const value = timeoutDuration(raw, label);
|
|
8708
|
+
return value === null ? undefined : value;
|
|
8709
|
+
}
|
|
8075
8710
|
function parsePolicy(opts) {
|
|
8076
8711
|
const catchUp = opts.catchUp ?? "latest";
|
|
8077
8712
|
if (!["none", "latest", "all"].includes(catchUp))
|
|
@@ -8088,6 +8723,45 @@ function parsePolicy(opts) {
|
|
|
8088
8723
|
leaseMs: positiveDuration(opts.lease, "--lease")
|
|
8089
8724
|
};
|
|
8090
8725
|
}
|
|
8726
|
+
function durationLabel(ms) {
|
|
8727
|
+
if (!ms || !Number.isFinite(ms))
|
|
8728
|
+
return "";
|
|
8729
|
+
const units = [
|
|
8730
|
+
[7 * 24 * 60 * 60 * 1000, "w"],
|
|
8731
|
+
[24 * 60 * 60 * 1000, "d"],
|
|
8732
|
+
[60 * 60 * 1000, "h"],
|
|
8733
|
+
[60 * 1000, "m"],
|
|
8734
|
+
[1000, "s"]
|
|
8735
|
+
];
|
|
8736
|
+
for (const [unitMs, label] of units) {
|
|
8737
|
+
if (ms % unitMs === 0)
|
|
8738
|
+
return `${ms / unitMs}${label}`;
|
|
8739
|
+
}
|
|
8740
|
+
return `${ms}ms`;
|
|
8741
|
+
}
|
|
8742
|
+
function scheduleLabel(schedule) {
|
|
8743
|
+
if (schedule.type === "once")
|
|
8744
|
+
return `once:${schedule.at}`;
|
|
8745
|
+
if (schedule.type === "interval")
|
|
8746
|
+
return `every:${durationLabel(schedule.everyMs) || `${schedule.everyMs}ms`}`;
|
|
8747
|
+
if (schedule.type === "cron")
|
|
8748
|
+
return `cron:${schedule.expression}`;
|
|
8749
|
+
return schedule.minIntervalMs ? `dynamic:min-${durationLabel(schedule.minIntervalMs) || `${schedule.minIntervalMs}ms`}` : "dynamic";
|
|
8750
|
+
}
|
|
8751
|
+
function targetLabel(target) {
|
|
8752
|
+
if (target.type === "command")
|
|
8753
|
+
return `runs command ${target.command}`;
|
|
8754
|
+
if (target.type === "agent")
|
|
8755
|
+
return `runs ${target.provider} agent${target.cwd ? ` in ${target.cwd}` : ""}`;
|
|
8756
|
+
return `runs workflow ${target.workflowId}`;
|
|
8757
|
+
}
|
|
8758
|
+
function defaultLoopDescription(name, schedule, target) {
|
|
8759
|
+
return [
|
|
8760
|
+
`Why: keep ${name} running as an OpenLoops scheduled automation.`,
|
|
8761
|
+
`How: ${targetLabel(target)} on cadence ${scheduleLabel(schedule)}.`,
|
|
8762
|
+
"Outcome: record each run, status, retries, and evidence in OpenLoops for operator review."
|
|
8763
|
+
].join(" ");
|
|
8764
|
+
}
|
|
8091
8765
|
function baseCreateInput(name, opts, target) {
|
|
8092
8766
|
const schedule = parseSchedule({
|
|
8093
8767
|
at: typeof opts.at === "string" ? opts.at : undefined,
|
|
@@ -8103,9 +8777,10 @@ function baseCreateInput(name, opts, target) {
|
|
|
8103
8777
|
retryDelay: typeof opts.retryDelay === "string" ? opts.retryDelay : undefined,
|
|
8104
8778
|
lease: typeof opts.lease === "string" ? opts.lease : undefined
|
|
8105
8779
|
});
|
|
8780
|
+
const explicitDescription = typeof opts.description === "string" && opts.description.trim() ? opts.description : undefined;
|
|
8106
8781
|
return {
|
|
8107
8782
|
name,
|
|
8108
|
-
description:
|
|
8783
|
+
description: explicitDescription ?? defaultLoopDescription(name, schedule, target),
|
|
8109
8784
|
schedule,
|
|
8110
8785
|
target,
|
|
8111
8786
|
goal: goalFromOpts(opts),
|
|
@@ -8228,7 +8903,7 @@ function runLocalCommandWithStdoutFile(command, args, opts = {}) {
|
|
|
8228
8903
|
return {
|
|
8229
8904
|
ok: result.status === 0,
|
|
8230
8905
|
status: result.status,
|
|
8231
|
-
stdout:
|
|
8906
|
+
stdout: readFileSync5(stdoutPath, "utf8"),
|
|
8232
8907
|
stderr: typeof result.stderr === "string" ? result.stderr : result.stderr?.toString() || "",
|
|
8233
8908
|
error: result.error ? String(result.error.message || result.error) : ""
|
|
8234
8909
|
};
|
|
@@ -8269,10 +8944,10 @@ function routeCursorsPath() {
|
|
|
8269
8944
|
}
|
|
8270
8945
|
function readRouteCursors() {
|
|
8271
8946
|
const path = routeCursorsPath();
|
|
8272
|
-
if (!
|
|
8947
|
+
if (!existsSync6(path))
|
|
8273
8948
|
return {};
|
|
8274
8949
|
try {
|
|
8275
|
-
const parsed = JSON.parse(
|
|
8950
|
+
const parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
8276
8951
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
8277
8952
|
} catch {
|
|
8278
8953
|
return {};
|
|
@@ -8436,31 +9111,46 @@ function taskEventField(data, keys) {
|
|
|
8436
9111
|
}
|
|
8437
9112
|
return;
|
|
8438
9113
|
}
|
|
8439
|
-
function
|
|
9114
|
+
function objectField2(value) {
|
|
8440
9115
|
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
8441
9116
|
}
|
|
8442
9117
|
function nestedObject(input, key) {
|
|
8443
|
-
return
|
|
9118
|
+
return objectField2(input[key]);
|
|
8444
9119
|
}
|
|
8445
9120
|
function taskEventRecords(data, metadata) {
|
|
8446
9121
|
const records = [data];
|
|
8447
9122
|
const dataTask = nestedObject(data, "task");
|
|
8448
|
-
if (dataTask)
|
|
9123
|
+
if (dataTask) {
|
|
8449
9124
|
records.push(dataTask);
|
|
9125
|
+
const dataTaskMetadata = nestedObject(dataTask, "metadata");
|
|
9126
|
+
if (dataTaskMetadata)
|
|
9127
|
+
records.push(dataTaskMetadata);
|
|
9128
|
+
}
|
|
8450
9129
|
const dataPayload = nestedObject(data, "payload");
|
|
8451
9130
|
if (dataPayload) {
|
|
8452
9131
|
records.push(dataPayload);
|
|
9132
|
+
const payloadMetadata = nestedObject(dataPayload, "metadata");
|
|
9133
|
+
if (payloadMetadata)
|
|
9134
|
+
records.push(payloadMetadata);
|
|
8453
9135
|
const payloadTask = nestedObject(dataPayload, "task");
|
|
8454
|
-
if (payloadTask)
|
|
9136
|
+
if (payloadTask) {
|
|
8455
9137
|
records.push(payloadTask);
|
|
9138
|
+
const payloadTaskMetadata = nestedObject(payloadTask, "metadata");
|
|
9139
|
+
if (payloadTaskMetadata)
|
|
9140
|
+
records.push(payloadTaskMetadata);
|
|
9141
|
+
}
|
|
8456
9142
|
}
|
|
8457
9143
|
const dataMetadata = nestedObject(data, "metadata");
|
|
8458
9144
|
if (dataMetadata)
|
|
8459
9145
|
records.push(dataMetadata);
|
|
8460
9146
|
records.push(metadata);
|
|
8461
9147
|
const metadataTask = nestedObject(metadata, "task");
|
|
8462
|
-
if (metadataTask)
|
|
9148
|
+
if (metadataTask) {
|
|
8463
9149
|
records.push(metadataTask);
|
|
9150
|
+
const metadataTaskMetadata = nestedObject(metadataTask, "metadata");
|
|
9151
|
+
if (metadataTaskMetadata)
|
|
9152
|
+
records.push(metadataTaskMetadata);
|
|
9153
|
+
}
|
|
8464
9154
|
const metadataAutomation = nestedObject(metadata, "automation");
|
|
8465
9155
|
if (metadataAutomation)
|
|
8466
9156
|
records.push(metadataAutomation);
|
|
@@ -8472,6 +9162,15 @@ function booleanLike(value) {
|
|
|
8472
9162
|
function hasTruthyField(records, keys) {
|
|
8473
9163
|
return records.some((record) => keys.some((key) => booleanLike(record[key])));
|
|
8474
9164
|
}
|
|
9165
|
+
function firstTruthyField(records, keys) {
|
|
9166
|
+
for (const record of records) {
|
|
9167
|
+
for (const key of keys) {
|
|
9168
|
+
if (booleanLike(record[key]))
|
|
9169
|
+
return key;
|
|
9170
|
+
}
|
|
9171
|
+
}
|
|
9172
|
+
return;
|
|
9173
|
+
}
|
|
8475
9174
|
function automationRecords(data, metadata) {
|
|
8476
9175
|
const records = [];
|
|
8477
9176
|
const dataAutomation = nestedObject(data, "automation");
|
|
@@ -8481,14 +9180,26 @@ function automationRecords(data, metadata) {
|
|
|
8481
9180
|
const dataTaskAutomation = dataTask ? nestedObject(dataTask, "automation") : undefined;
|
|
8482
9181
|
if (dataTaskAutomation)
|
|
8483
9182
|
records.push(dataTaskAutomation);
|
|
9183
|
+
const dataTaskMetadata = dataTask ? nestedObject(dataTask, "metadata") : undefined;
|
|
9184
|
+
const dataTaskMetadataAutomation = dataTaskMetadata ? nestedObject(dataTaskMetadata, "automation") : undefined;
|
|
9185
|
+
if (dataTaskMetadataAutomation)
|
|
9186
|
+
records.push(dataTaskMetadataAutomation);
|
|
8484
9187
|
const dataPayload = nestedObject(data, "payload");
|
|
8485
9188
|
const payloadAutomation = dataPayload ? nestedObject(dataPayload, "automation") : undefined;
|
|
8486
9189
|
if (payloadAutomation)
|
|
8487
9190
|
records.push(payloadAutomation);
|
|
9191
|
+
const payloadMetadata = dataPayload ? nestedObject(dataPayload, "metadata") : undefined;
|
|
9192
|
+
const payloadMetadataAutomation = payloadMetadata ? nestedObject(payloadMetadata, "automation") : undefined;
|
|
9193
|
+
if (payloadMetadataAutomation)
|
|
9194
|
+
records.push(payloadMetadataAutomation);
|
|
8488
9195
|
const payloadTask = dataPayload ? nestedObject(dataPayload, "task") : undefined;
|
|
8489
9196
|
const payloadTaskAutomation = payloadTask ? nestedObject(payloadTask, "automation") : undefined;
|
|
8490
9197
|
if (payloadTaskAutomation)
|
|
8491
9198
|
records.push(payloadTaskAutomation);
|
|
9199
|
+
const payloadTaskMetadata = payloadTask ? nestedObject(payloadTask, "metadata") : undefined;
|
|
9200
|
+
const payloadTaskMetadataAutomation = payloadTaskMetadata ? nestedObject(payloadTaskMetadata, "automation") : undefined;
|
|
9201
|
+
if (payloadTaskMetadataAutomation)
|
|
9202
|
+
records.push(payloadTaskMetadataAutomation);
|
|
8492
9203
|
const dataMetadata = nestedObject(data, "metadata");
|
|
8493
9204
|
const dataMetadataAutomation = dataMetadata ? nestedObject(dataMetadata, "automation") : undefined;
|
|
8494
9205
|
if (dataMetadataAutomation)
|
|
@@ -8500,9 +9211,13 @@ function automationRecords(data, metadata) {
|
|
|
8500
9211
|
const metadataTaskAutomation = metadataTask ? nestedObject(metadataTask, "automation") : undefined;
|
|
8501
9212
|
if (metadataTaskAutomation)
|
|
8502
9213
|
records.push(metadataTaskAutomation);
|
|
9214
|
+
const metadataTaskMetadata = metadataTask ? nestedObject(metadataTask, "metadata") : undefined;
|
|
9215
|
+
const metadataTaskMetadataAutomation = metadataTaskMetadata ? nestedObject(metadataTaskMetadata, "automation") : undefined;
|
|
9216
|
+
if (metadataTaskMetadataAutomation)
|
|
9217
|
+
records.push(metadataTaskMetadataAutomation);
|
|
8503
9218
|
return records;
|
|
8504
9219
|
}
|
|
8505
|
-
function
|
|
9220
|
+
function tagsFromValue2(value) {
|
|
8506
9221
|
if (Array.isArray(value))
|
|
8507
9222
|
return value.map((entry) => String(entry).trim()).filter(Boolean);
|
|
8508
9223
|
if (typeof value === "string")
|
|
@@ -8512,40 +9227,381 @@ function tagsFromValue(value) {
|
|
|
8512
9227
|
function taskEventTags(records) {
|
|
8513
9228
|
const tags = new Set;
|
|
8514
9229
|
for (const record of records) {
|
|
8515
|
-
for (const tag of
|
|
9230
|
+
for (const tag of tagsFromValue2(record.tags ?? record.task_tags ?? record.taskTags))
|
|
8516
9231
|
tags.add(tag);
|
|
8517
9232
|
}
|
|
8518
9233
|
return [...tags];
|
|
8519
9234
|
}
|
|
9235
|
+
var ROUTE_DISALLOWED_TASK_TAGS = new Set([
|
|
9236
|
+
"no-auto",
|
|
9237
|
+
"manual",
|
|
9238
|
+
"manual-required",
|
|
9239
|
+
"approval-required",
|
|
9240
|
+
"blocked",
|
|
9241
|
+
"completed",
|
|
9242
|
+
"done",
|
|
9243
|
+
"cancelled",
|
|
9244
|
+
"canceled",
|
|
9245
|
+
"failed",
|
|
9246
|
+
"archived"
|
|
9247
|
+
]);
|
|
9248
|
+
var ROUTE_MANUAL_GATE_FIELDS = [
|
|
9249
|
+
"no_auto",
|
|
9250
|
+
"noAuto",
|
|
9251
|
+
"manual",
|
|
9252
|
+
"manual_required",
|
|
9253
|
+
"manualRequired",
|
|
9254
|
+
"requires_approval",
|
|
9255
|
+
"requiresApproval",
|
|
9256
|
+
"approval_required",
|
|
9257
|
+
"approvalRequired"
|
|
9258
|
+
];
|
|
8520
9259
|
function taskRouteEligibility(data, metadata) {
|
|
8521
9260
|
const records = taskEventRecords(data, metadata);
|
|
9261
|
+
const automation = automationRecords(data, metadata);
|
|
8522
9262
|
const tags = taskEventTags(records);
|
|
8523
|
-
const hasRouteOptIn = tags.includes("auto:route") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed"]) || hasTruthyField(
|
|
9263
|
+
const hasRouteOptIn = tags.includes("auto:route") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed"]) || hasTruthyField(automation, ["allowed"]);
|
|
8524
9264
|
if (!hasRouteOptIn)
|
|
8525
9265
|
return { eligible: false, reason: "missing explicit route opt-in", tags };
|
|
8526
9266
|
const status = taskEventField(data, ["status", "task_status", "taskStatus"])?.toLowerCase();
|
|
8527
9267
|
if (status && ["blocked", "completed", "done", "cancelled", "canceled", "failed", "archived"].includes(status)) {
|
|
8528
9268
|
return { eligible: false, reason: `task status is not routable: ${status}`, tags };
|
|
8529
9269
|
}
|
|
8530
|
-
const disallowedTags = tags.filter((tag) =>
|
|
9270
|
+
const disallowedTags = tags.filter((tag) => ROUTE_DISALLOWED_TASK_TAGS.has(tag.toLowerCase()));
|
|
8531
9271
|
if (disallowedTags.length)
|
|
8532
9272
|
return { eligible: false, reason: `task has disallowed tag: ${disallowedTags[0]}`, tags };
|
|
8533
|
-
|
|
8534
|
-
|
|
8535
|
-
|
|
8536
|
-
"manual",
|
|
8537
|
-
"manual_required",
|
|
8538
|
-
"manualRequired",
|
|
8539
|
-
"requires_approval",
|
|
8540
|
-
"requiresApproval",
|
|
8541
|
-
"approval_required",
|
|
8542
|
-
"approvalRequired"
|
|
8543
|
-
])) {
|
|
8544
|
-
return { eligible: false, reason: "task metadata requires manual or approval-gated handling", tags };
|
|
9273
|
+
const manualGateField = firstTruthyField([...records, ...automation], ROUTE_MANUAL_GATE_FIELDS);
|
|
9274
|
+
if (manualGateField) {
|
|
9275
|
+
return { eligible: false, reason: `task metadata requires manual or approval-gated handling: ${manualGateField}`, tags };
|
|
8545
9276
|
}
|
|
8546
9277
|
return { eligible: true, tags };
|
|
8547
9278
|
}
|
|
8548
|
-
|
|
9279
|
+
var PR_AUTHOR_FIELDS = [
|
|
9280
|
+
"github_author",
|
|
9281
|
+
"githubAuthor",
|
|
9282
|
+
"github_pr_author",
|
|
9283
|
+
"githubPrAuthor",
|
|
9284
|
+
"pr_author",
|
|
9285
|
+
"prAuthor",
|
|
9286
|
+
"pull_request_author",
|
|
9287
|
+
"pullRequestAuthor",
|
|
9288
|
+
"author_login",
|
|
9289
|
+
"authorLogin"
|
|
9290
|
+
];
|
|
9291
|
+
var PR_REVIEWER_FIELDS = [
|
|
9292
|
+
"github_reviewer",
|
|
9293
|
+
"githubReviewer",
|
|
9294
|
+
"github_review_actor",
|
|
9295
|
+
"githubReviewActor",
|
|
9296
|
+
"github_review_login",
|
|
9297
|
+
"githubReviewLogin",
|
|
9298
|
+
"github_actor",
|
|
9299
|
+
"githubActor",
|
|
9300
|
+
"reviewer_login",
|
|
9301
|
+
"reviewerLogin",
|
|
9302
|
+
"review_actor",
|
|
9303
|
+
"reviewActor",
|
|
9304
|
+
"merge_actor",
|
|
9305
|
+
"mergeActor"
|
|
9306
|
+
];
|
|
9307
|
+
var PR_REVIEWER_POOL_FIELDS = [
|
|
9308
|
+
"github_reviewer_pool",
|
|
9309
|
+
"githubReviewerPool",
|
|
9310
|
+
"github_review_pool",
|
|
9311
|
+
"githubReviewPool",
|
|
9312
|
+
"github_reviewers",
|
|
9313
|
+
"githubReviewers",
|
|
9314
|
+
"reviewer_pool",
|
|
9315
|
+
"reviewerPool",
|
|
9316
|
+
"reviewers"
|
|
9317
|
+
];
|
|
9318
|
+
var PR_REVIEW_REQUIRED_FIELDS = [
|
|
9319
|
+
"github_review_required",
|
|
9320
|
+
"githubReviewRequired",
|
|
9321
|
+
"pr_review_required",
|
|
9322
|
+
"prReviewRequired",
|
|
9323
|
+
"review_required",
|
|
9324
|
+
"reviewRequired",
|
|
9325
|
+
"requires_non_author_review",
|
|
9326
|
+
"requiresNonAuthorReview",
|
|
9327
|
+
"branch_protection_review_required",
|
|
9328
|
+
"branchProtectionReviewRequired"
|
|
9329
|
+
];
|
|
9330
|
+
function githubLogin(value) {
|
|
9331
|
+
if (!value?.trim())
|
|
9332
|
+
return;
|
|
9333
|
+
const login = value.trim().replace(/^@/, "");
|
|
9334
|
+
return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login) ? login : undefined;
|
|
9335
|
+
}
|
|
9336
|
+
function githubLogins(values) {
|
|
9337
|
+
const seen = new Set;
|
|
9338
|
+
const logins = [];
|
|
9339
|
+
for (const value of values) {
|
|
9340
|
+
const login = githubLogin(value);
|
|
9341
|
+
if (!login)
|
|
9342
|
+
continue;
|
|
9343
|
+
const key = login.toLowerCase();
|
|
9344
|
+
if (seen.has(key))
|
|
9345
|
+
continue;
|
|
9346
|
+
seen.add(key);
|
|
9347
|
+
logins.push(login);
|
|
9348
|
+
}
|
|
9349
|
+
return logins;
|
|
9350
|
+
}
|
|
9351
|
+
function routeEvidenceText(records) {
|
|
9352
|
+
const fields = [
|
|
9353
|
+
"title",
|
|
9354
|
+
"task_title",
|
|
9355
|
+
"taskTitle",
|
|
9356
|
+
"description",
|
|
9357
|
+
"body",
|
|
9358
|
+
"reason",
|
|
9359
|
+
"message",
|
|
9360
|
+
"fingerprint",
|
|
9361
|
+
"reviewDecision",
|
|
9362
|
+
"review_decision",
|
|
9363
|
+
"mergeStateStatus",
|
|
9364
|
+
"merge_state_status"
|
|
9365
|
+
];
|
|
9366
|
+
const values = [];
|
|
9367
|
+
for (const record of records) {
|
|
9368
|
+
for (const field of fields)
|
|
9369
|
+
values.push(...recordFieldValues(record, field));
|
|
9370
|
+
values.push(...tagsFromValue2(record.tags ?? record.task_tags ?? record.taskTags));
|
|
9371
|
+
}
|
|
9372
|
+
return values.join(`
|
|
9373
|
+
`);
|
|
9374
|
+
}
|
|
9375
|
+
function authorFromPrText(text) {
|
|
9376
|
+
const patterns = [
|
|
9377
|
+
/\bauthor\s+(?:is\s+also|is|=|:)\s+@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/i,
|
|
9378
|
+
/\bPR\s*#?\d+\s+author\s+(?:is\s+also|is|=|:)\s+@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/i,
|
|
9379
|
+
/\bgithub\s+author\s+(?:is\s+also|is|=|:)\s+@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/i
|
|
9380
|
+
];
|
|
9381
|
+
for (const pattern of patterns) {
|
|
9382
|
+
const match = pattern.exec(text);
|
|
9383
|
+
const login = githubLogin(match?.[1]);
|
|
9384
|
+
if (login)
|
|
9385
|
+
return login;
|
|
9386
|
+
}
|
|
9387
|
+
return;
|
|
9388
|
+
}
|
|
9389
|
+
function prReviewRoutingDecision(data, metadata, opts) {
|
|
9390
|
+
const records = [...taskEventRecords(data, metadata), ...automationRecords(data, metadata)];
|
|
9391
|
+
const text = routeEvidenceText(records);
|
|
9392
|
+
const signals = [];
|
|
9393
|
+
const hasPrReference = /github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/i.test(text) || /\bgithub-pr:[^\s]+#\d+/i.test(text) || /\bpull request\b/i.test(text) || /\bpr\s*#?\d+\b/i.test(text);
|
|
9394
|
+
const reviewRequiredByField = hasTruthyField(records, PR_REVIEW_REQUIRED_FIELDS);
|
|
9395
|
+
const reviewRequiredByText = /reviewdecision\s*[:=]\s*review_required/i.test(text) || /\breview_required\b/i.test(text) || /\breview required\b/i.test(text) || /\brequires?\s+\d+\s+approving review/i.test(text) || /\bbranch protection\b[\s\S]{0,160}\bapproving review/i.test(text);
|
|
9396
|
+
const mergeBlockedByText = /mergestatestatus\s*[:=]\s*blocked/i.test(text) || /\bmerge state status\b[\s:=]+blocked/i.test(text);
|
|
9397
|
+
const approvalIntent = /\b(approve|approval|merge|review)\b/i.test(text);
|
|
9398
|
+
if (reviewRequiredByField)
|
|
9399
|
+
signals.push("review-required-field");
|
|
9400
|
+
if (reviewRequiredByText)
|
|
9401
|
+
signals.push("review-required-text");
|
|
9402
|
+
if (mergeBlockedByText)
|
|
9403
|
+
signals.push("merge-blocked-text");
|
|
9404
|
+
if (approvalIntent && hasPrReference)
|
|
9405
|
+
signals.push("pr-approval-intent");
|
|
9406
|
+
const required = hasPrReference && (reviewRequiredByField || reviewRequiredByText || mergeBlockedByText || approvalIntent);
|
|
9407
|
+
if (!required)
|
|
9408
|
+
return { required: false, allowed: true, reviewers: [], signals };
|
|
9409
|
+
const author = githubLogin(firstRouteField(records, PR_AUTHOR_FIELDS)) ?? authorFromPrText(text);
|
|
9410
|
+
const reviewers = githubLogins([
|
|
9411
|
+
opts.githubReviewer,
|
|
9412
|
+
...splitList(opts.githubReviewerPool) ?? [],
|
|
9413
|
+
...PR_REVIEWER_FIELDS.flatMap((field) => routeFieldValues(records, field)),
|
|
9414
|
+
...PR_REVIEWER_POOL_FIELDS.flatMap((field) => routeFieldValues(records, field))
|
|
9415
|
+
]);
|
|
9416
|
+
const selectedReviewer = author ? reviewers.find((reviewer) => reviewer.toLowerCase() !== author.toLowerCase()) : undefined;
|
|
9417
|
+
if (!author) {
|
|
9418
|
+
return {
|
|
9419
|
+
required: true,
|
|
9420
|
+
allowed: false,
|
|
9421
|
+
reason: "PR approval/merge route requires PR author evidence before selecting a non-author GitHub reviewer",
|
|
9422
|
+
reviewers,
|
|
9423
|
+
signals
|
|
9424
|
+
};
|
|
9425
|
+
}
|
|
9426
|
+
if (!reviewers.length) {
|
|
9427
|
+
return {
|
|
9428
|
+
required: true,
|
|
9429
|
+
allowed: false,
|
|
9430
|
+
reason: "PR approval/merge route requires --github-reviewer, --github-reviewer-pool, or task metadata github_reviewer/github_reviewer_pool with a login different from the PR author",
|
|
9431
|
+
author,
|
|
9432
|
+
reviewers,
|
|
9433
|
+
signals
|
|
9434
|
+
};
|
|
9435
|
+
}
|
|
9436
|
+
if (!selectedReviewer) {
|
|
9437
|
+
return {
|
|
9438
|
+
required: true,
|
|
9439
|
+
allowed: false,
|
|
9440
|
+
reason: `PR approval/merge route reviewer candidates match PR author ${author}; self-review is not routable`,
|
|
9441
|
+
author,
|
|
9442
|
+
reviewers,
|
|
9443
|
+
signals
|
|
9444
|
+
};
|
|
9445
|
+
}
|
|
9446
|
+
return {
|
|
9447
|
+
required: true,
|
|
9448
|
+
allowed: true,
|
|
9449
|
+
author,
|
|
9450
|
+
reviewers,
|
|
9451
|
+
selectedReviewer,
|
|
9452
|
+
signals
|
|
9453
|
+
};
|
|
9454
|
+
}
|
|
9455
|
+
var SUPPORTED_AGENT_PROVIDERS = new Set(["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"]);
|
|
9456
|
+
var PROVIDER_HINT_FIELDS = ["provider_hint", "providerHint", "route_provider", "routeProvider", "agent_provider", "agentProvider"];
|
|
9457
|
+
var AUTH_PROFILE_HINT_FIELDS = ["auth_profile", "authProfile", "codewith_profile", "codewithProfile", "profile"];
|
|
9458
|
+
var AUTH_PROFILE_POOL_HINT_FIELDS = ["auth_profile_pool", "authProfilePool", "codewith_profile_pool", "codewithProfilePool", "profile_pool", "profilePool"];
|
|
9459
|
+
var ACCOUNT_HINT_FIELDS = ["account", "account_profile", "accountProfile", "openaccounts_profile", "openaccountsProfile"];
|
|
9460
|
+
var ACCOUNT_POOL_HINT_FIELDS = ["account_pool", "accountPool", "openaccounts_pool", "openaccountsPool"];
|
|
9461
|
+
var ACCOUNT_TOOL_HINT_FIELDS = ["account_tool", "accountTool", "openaccounts_tool", "openaccountsTool"];
|
|
9462
|
+
function canonicalRouteField(value) {
|
|
9463
|
+
return value.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
9464
|
+
}
|
|
9465
|
+
function normalizeAgentProvider(value, source) {
|
|
9466
|
+
const provider = value?.trim().toLowerCase();
|
|
9467
|
+
if (provider && SUPPORTED_AGENT_PROVIDERS.has(provider))
|
|
9468
|
+
return provider;
|
|
9469
|
+
const expected = [...SUPPORTED_AGENT_PROVIDERS].join(", ");
|
|
9470
|
+
throw new Error(`unsupported provider${provider ? ` "${provider}"` : ""} from ${source}; expected one of: ${expected}`);
|
|
9471
|
+
}
|
|
9472
|
+
function stringValuesFromUnknown(value) {
|
|
9473
|
+
if (Array.isArray(value))
|
|
9474
|
+
return value.flatMap((entry) => stringValuesFromUnknown(entry));
|
|
9475
|
+
if (typeof value === "string")
|
|
9476
|
+
return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
9477
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
9478
|
+
return [String(value)];
|
|
9479
|
+
return [];
|
|
9480
|
+
}
|
|
9481
|
+
function recordFieldValues(record, field) {
|
|
9482
|
+
const expected = canonicalRouteField(field);
|
|
9483
|
+
const values = [];
|
|
9484
|
+
for (const [key, value] of Object.entries(record)) {
|
|
9485
|
+
if (canonicalRouteField(key) === expected)
|
|
9486
|
+
values.push(...stringValuesFromUnknown(value));
|
|
9487
|
+
}
|
|
9488
|
+
return values;
|
|
9489
|
+
}
|
|
9490
|
+
function routeFieldValues(records, field) {
|
|
9491
|
+
if (canonicalRouteField(field) === "tags")
|
|
9492
|
+
return taskEventTags(records);
|
|
9493
|
+
return records.flatMap((record) => recordFieldValues(record, field));
|
|
9494
|
+
}
|
|
9495
|
+
function firstRouteField(records, fields) {
|
|
9496
|
+
for (const field of fields) {
|
|
9497
|
+
const value = routeFieldValues(records, field).find(Boolean);
|
|
9498
|
+
if (value)
|
|
9499
|
+
return value;
|
|
9500
|
+
}
|
|
9501
|
+
return;
|
|
9502
|
+
}
|
|
9503
|
+
function routeFieldList(records, fields) {
|
|
9504
|
+
for (const field of fields) {
|
|
9505
|
+
const values = routeFieldValues(records, field);
|
|
9506
|
+
if (values.length)
|
|
9507
|
+
return values;
|
|
9508
|
+
}
|
|
9509
|
+
return;
|
|
9510
|
+
}
|
|
9511
|
+
function parseProviderRoutingRule(raw) {
|
|
9512
|
+
const rule = raw.trim();
|
|
9513
|
+
const selectorIndex = rule.indexOf(":");
|
|
9514
|
+
if (selectorIndex <= 0)
|
|
9515
|
+
throw new Error(`invalid --provider-rule "${raw}", expected field=value:provider[:profile1,profile2]`);
|
|
9516
|
+
const selector = rule.slice(0, selectorIndex).trim();
|
|
9517
|
+
const route = rule.slice(selectorIndex + 1).trim();
|
|
9518
|
+
const equalsIndex = selector.indexOf("=");
|
|
9519
|
+
if (equalsIndex <= 0)
|
|
9520
|
+
throw new Error(`invalid --provider-rule "${raw}", expected field=value:provider[:profile1,profile2]`);
|
|
9521
|
+
const field = selector.slice(0, equalsIndex).trim();
|
|
9522
|
+
const value = selector.slice(equalsIndex + 1).trim();
|
|
9523
|
+
const providerIndex = route.indexOf(":");
|
|
9524
|
+
const providerRaw = providerIndex >= 0 ? route.slice(0, providerIndex).trim() : route;
|
|
9525
|
+
const profilesRaw = providerIndex >= 0 ? route.slice(providerIndex + 1).trim() : undefined;
|
|
9526
|
+
if (!field || !value || !providerRaw)
|
|
9527
|
+
throw new Error(`invalid --provider-rule "${raw}", expected field=value:provider[:profile1,profile2]`);
|
|
9528
|
+
return {
|
|
9529
|
+
raw,
|
|
9530
|
+
field,
|
|
9531
|
+
value,
|
|
9532
|
+
provider: normalizeAgentProvider(providerRaw, `provider rule ${field}=${value}`),
|
|
9533
|
+
profiles: splitList(profilesRaw)
|
|
9534
|
+
};
|
|
9535
|
+
}
|
|
9536
|
+
function parseProviderRoutingRules(values) {
|
|
9537
|
+
return (values ?? []).map(parseProviderRoutingRule);
|
|
9538
|
+
}
|
|
9539
|
+
function providerRuleMatches(rule, records) {
|
|
9540
|
+
const expected = rule.value.trim().toLowerCase();
|
|
9541
|
+
return routeFieldValues(records, rule.field).some((value) => value.trim().toLowerCase() === expected);
|
|
9542
|
+
}
|
|
9543
|
+
function accountRef(profile, tool) {
|
|
9544
|
+
return profile ? { profile, tool } : undefined;
|
|
9545
|
+
}
|
|
9546
|
+
function accountRefs(profiles, tool) {
|
|
9547
|
+
return profiles?.length ? profiles.map((profile) => ({ profile, tool })) : undefined;
|
|
9548
|
+
}
|
|
9549
|
+
function providerRoutingPublic(decision) {
|
|
9550
|
+
return {
|
|
9551
|
+
provider: decision.provider,
|
|
9552
|
+
source: decision.source,
|
|
9553
|
+
reason: decision.reason,
|
|
9554
|
+
rule: decision.rule,
|
|
9555
|
+
authProfile: decision.authProfile,
|
|
9556
|
+
authProfilePool: decision.authProfilePool,
|
|
9557
|
+
account: decision.account,
|
|
9558
|
+
accountPool: decision.accountPool
|
|
9559
|
+
};
|
|
9560
|
+
}
|
|
9561
|
+
function resolveProviderRouting(data, metadata, opts) {
|
|
9562
|
+
const records = [...taskEventRecords(data, metadata), ...automationRecords(data, metadata)];
|
|
9563
|
+
const matchedRule = parseProviderRoutingRules(opts.providerRule).find((rule) => providerRuleMatches(rule, records));
|
|
9564
|
+
const metadataProvider = matchedRule || opts.provider ? undefined : firstRouteField(records, PROVIDER_HINT_FIELDS);
|
|
9565
|
+
const provider = matchedRule ? matchedRule.provider : opts.provider ? normalizeAgentProvider(opts.provider, "--provider") : metadataProvider ? normalizeAgentProvider(metadataProvider, "task metadata provider hint") : "codewith";
|
|
9566
|
+
const source = matchedRule ? "rule" : opts.provider ? "option" : metadataProvider ? "metadata" : "default";
|
|
9567
|
+
const metadataProfilePool = routeFieldList(records, provider === "codewith" ? AUTH_PROFILE_POOL_HINT_FIELDS : [...ACCOUNT_POOL_HINT_FIELDS, ...AUTH_PROFILE_POOL_HINT_FIELDS]);
|
|
9568
|
+
const metadataProfile = firstRouteField(records, provider === "codewith" ? AUTH_PROFILE_HINT_FIELDS : [...ACCOUNT_HINT_FIELDS, ...AUTH_PROFILE_HINT_FIELDS]);
|
|
9569
|
+
const profilePool = matchedRule?.profiles ?? metadataProfilePool;
|
|
9570
|
+
const profile = metadataProfile;
|
|
9571
|
+
const metadataAccountTool = firstRouteField(records, ACCOUNT_TOOL_HINT_FIELDS);
|
|
9572
|
+
const accountTool = opts.accountTool ?? (matchedRule?.profiles ? undefined : metadataAccountTool) ?? (provider === "codewith" ? undefined : provider);
|
|
9573
|
+
const hasResolvedAccountPool = provider !== "codewith" && Boolean((profilePool?.length ?? 0) || profile);
|
|
9574
|
+
if (opts.accountTool && !opts.account && !opts.accountPool && !opts.triageAccount && !opts.plannerAccount && !opts.workerAccount && !opts.verifierAccount && !hasResolvedAccountPool) {
|
|
9575
|
+
throw new Error("--account-tool requires --account, --account-pool, --triage-account, --planner-account, --worker-account, --verifier-account, metadata account hints, or provider-rule profiles");
|
|
9576
|
+
}
|
|
9577
|
+
if (provider === "codewith") {
|
|
9578
|
+
const cliAuthProfilePool = splitList(opts.authProfilePool);
|
|
9579
|
+
return {
|
|
9580
|
+
provider,
|
|
9581
|
+
source,
|
|
9582
|
+
reason: matchedRule ? `matched provider rule ${matchedRule.field}=${matchedRule.value}` : metadataProvider ? "selected provider from task metadata" : opts.provider ? "selected provider from --provider" : "selected default provider",
|
|
9583
|
+
rule: matchedRule,
|
|
9584
|
+
authProfile: opts.authProfile ?? profile,
|
|
9585
|
+
authProfilePool: matchedRule?.profiles ?? cliAuthProfilePool ?? (opts.authProfile ? undefined : metadataProfilePool),
|
|
9586
|
+
account: accountRef(opts.account, opts.accountTool),
|
|
9587
|
+
accountPool: accountPoolFromOpts(opts)
|
|
9588
|
+
};
|
|
9589
|
+
}
|
|
9590
|
+
if (opts.authProfile || opts.authProfilePool) {
|
|
9591
|
+
throw new Error(`--auth-profile and --auth-profile-pool are supported only for --provider codewith; use OpenAccounts account profiles for ${provider}`);
|
|
9592
|
+
}
|
|
9593
|
+
const cliAccountPool = accountPoolFromOpts(opts);
|
|
9594
|
+
const account = opts.account ? accountRef(opts.account, opts.accountTool ?? provider) : accountRef(profile, accountTool);
|
|
9595
|
+
return {
|
|
9596
|
+
provider,
|
|
9597
|
+
source,
|
|
9598
|
+
reason: matchedRule ? `matched provider rule ${matchedRule.field}=${matchedRule.value}` : metadataProvider ? "selected provider from task metadata" : opts.provider ? "selected provider from --provider" : "selected default provider",
|
|
9599
|
+
rule: matchedRule,
|
|
9600
|
+
account,
|
|
9601
|
+
accountPool: matchedRule?.profiles ? accountRefs(matchedRule.profiles, accountTool) : cliAccountPool ?? (opts.account ? undefined : accountRefs(metadataProfilePool, accountTool))
|
|
9602
|
+
};
|
|
9603
|
+
}
|
|
9604
|
+
function skippedDrainTask(task, event, reason, extra = {}) {
|
|
8549
9605
|
const taskId = taskField(task, ["id", "task_id", "taskId"]) ?? event?.subject ?? "unknown";
|
|
8550
9606
|
return {
|
|
8551
9607
|
kind: "skipped",
|
|
@@ -8554,7 +9610,8 @@ function skippedDrainTask(task, event, reason) {
|
|
|
8554
9610
|
reason,
|
|
8555
9611
|
taskId,
|
|
8556
9612
|
event,
|
|
8557
|
-
routeError: true
|
|
9613
|
+
routeError: true,
|
|
9614
|
+
...extra
|
|
8558
9615
|
},
|
|
8559
9616
|
human: `skipped task ${taskId}: ${reason}`
|
|
8560
9617
|
};
|
|
@@ -8562,6 +9619,32 @@ function skippedDrainTask(task, event, reason) {
|
|
|
8562
9619
|
function isSkippableDrainRouteError(message) {
|
|
8563
9620
|
return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
|
|
8564
9621
|
}
|
|
9622
|
+
function todosMutationSummary(result) {
|
|
9623
|
+
return {
|
|
9624
|
+
ok: result.ok,
|
|
9625
|
+
status: result.status,
|
|
9626
|
+
error: result.ok ? undefined : redact(result.stderr || result.error || "todos mutation failed", 320)
|
|
9627
|
+
};
|
|
9628
|
+
}
|
|
9629
|
+
function markInvalidDrainTaskNonRouteable(todosProject, task, reason) {
|
|
9630
|
+
const taskId = taskField(task, ["id", "task_id", "taskId"]);
|
|
9631
|
+
if (!taskId)
|
|
9632
|
+
return { attempted: false, reason: "task id missing" };
|
|
9633
|
+
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.`;
|
|
9634
|
+
const commentResult = runLocalCommand("todos", ["--project", todosProject, "comment", taskId, comment], { timeoutMs: 30000 });
|
|
9635
|
+
const tagResult = runLocalCommand("todos", ["--project", todosProject, "tag", taskId, "no-auto"], { timeoutMs: 30000 });
|
|
9636
|
+
const untagResult = runLocalCommand("todos", ["--project", todosProject, "untag", taskId, "auto:route"], { timeoutMs: 30000 });
|
|
9637
|
+
const ok = commentResult.ok && tagResult.ok && untagResult.ok;
|
|
9638
|
+
return {
|
|
9639
|
+
ok,
|
|
9640
|
+
attempted: true,
|
|
9641
|
+
taskId,
|
|
9642
|
+
error: ok ? undefined : "one or more source task updates failed; inspect per-command results",
|
|
9643
|
+
comment: todosMutationSummary(commentResult),
|
|
9644
|
+
tagNoAuto: todosMutationSummary(tagResult),
|
|
9645
|
+
untagAutoRoute: todosMutationSummary(untagResult)
|
|
9646
|
+
};
|
|
9647
|
+
}
|
|
8565
9648
|
function generatedRouteSandboxPreflight(workflow) {
|
|
8566
9649
|
const checks = [];
|
|
8567
9650
|
for (const step of workflow.steps) {
|
|
@@ -8687,6 +9770,28 @@ var TODOS_TASK_ROUTE_TEMPLATE_IDS = new Set([
|
|
|
8687
9770
|
TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
|
|
8688
9771
|
TASK_LIFECYCLE_TEMPLATE_ID
|
|
8689
9772
|
]);
|
|
9773
|
+
var UNCLEARED_ROUTE_WORK_ITEM_STATUSES = new Set([
|
|
9774
|
+
"admitted",
|
|
9775
|
+
"running",
|
|
9776
|
+
"succeeded",
|
|
9777
|
+
"failed",
|
|
9778
|
+
"dead_letter",
|
|
9779
|
+
"cancelled"
|
|
9780
|
+
]);
|
|
9781
|
+
function isUnclearedRouteWorkItem(item) {
|
|
9782
|
+
return UNCLEARED_ROUTE_WORK_ITEM_STATUSES.has(item.status);
|
|
9783
|
+
}
|
|
9784
|
+
function isExistingGitProjectPath(path) {
|
|
9785
|
+
const result = spawnSync5("git", ["-C", path, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8" });
|
|
9786
|
+
return result.status === 0;
|
|
9787
|
+
}
|
|
9788
|
+
function validateRequiredRouteWorktreeProjectPath(opts, projectPath) {
|
|
9789
|
+
if ((opts.worktreeMode ?? "auto") !== "required")
|
|
9790
|
+
return;
|
|
9791
|
+
if (!isExistingGitProjectPath(projectPath)) {
|
|
9792
|
+
throw new Error(`worktreeMode=required but projectPath is not an existing git repository: ${projectPath}`);
|
|
9793
|
+
}
|
|
9794
|
+
}
|
|
8690
9795
|
function todosTaskRouteTemplateId(opts) {
|
|
8691
9796
|
const id = (opts.template ?? TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).trim();
|
|
8692
9797
|
if (!TODOS_TASK_ROUTE_TEMPLATE_IDS.has(id)) {
|
|
@@ -8695,7 +9800,7 @@ function todosTaskRouteTemplateId(opts) {
|
|
|
8695
9800
|
return id;
|
|
8696
9801
|
}
|
|
8697
9802
|
async function readEventEnvelopeInput(opts = {}) {
|
|
8698
|
-
const raw = opts.eventJson ?? (opts.eventFile ?
|
|
9803
|
+
const raw = opts.eventJson ?? (opts.eventFile ? readFileSync5(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
|
|
8699
9804
|
const event = JSON.parse(raw);
|
|
8700
9805
|
if (!event || typeof event !== "object" || Array.isArray(event))
|
|
8701
9806
|
throw new Error("event JSON must be an object");
|
|
@@ -8748,8 +9853,8 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
8748
9853
|
const store2 = new Store;
|
|
8749
9854
|
try {
|
|
8750
9855
|
const existingItem = store2.findWorkflowWorkItem("todos-task", idempotencyKey);
|
|
8751
|
-
if (existingItem
|
|
8752
|
-
const existingLoop = store2.getLoop(existingItem.loopId);
|
|
9856
|
+
if (existingItem && isUnclearedRouteWorkItem(existingItem)) {
|
|
9857
|
+
const existingLoop = existingItem.loopId ? store2.getLoop(existingItem.loopId) : undefined;
|
|
8753
9858
|
const existingWorkflow = existingItem.workflowId ? store2.getWorkflow(existingItem.workflowId) : undefined;
|
|
8754
9859
|
const existingInvocation = store2.getWorkflowInvocation(existingItem.invocationId);
|
|
8755
9860
|
return {
|
|
@@ -8771,12 +9876,27 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
8771
9876
|
store2.close();
|
|
8772
9877
|
}
|
|
8773
9878
|
}
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
9879
|
+
validateRequiredRouteWorktreeProjectPath(opts, projectPath);
|
|
9880
|
+
const prReviewRouting = prReviewRoutingDecision(data, metadata, opts);
|
|
9881
|
+
if (prReviewRouting.required && !prReviewRouting.allowed) {
|
|
9882
|
+
return {
|
|
9883
|
+
kind: "skipped",
|
|
9884
|
+
value: {
|
|
9885
|
+
skipped: true,
|
|
9886
|
+
reason: prReviewRouting.reason,
|
|
9887
|
+
event,
|
|
9888
|
+
taskId,
|
|
9889
|
+
routeError: true,
|
|
9890
|
+
prReviewRouting
|
|
9891
|
+
},
|
|
9892
|
+
human: `skipped task ${taskId}: ${prReviewRouting.reason}`
|
|
9893
|
+
};
|
|
9894
|
+
}
|
|
9895
|
+
const providerRouting = resolveProviderRouting(data, metadata, opts);
|
|
9896
|
+
const provider = providerRouting.provider;
|
|
8777
9897
|
const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
|
|
8778
9898
|
const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
|
|
8779
|
-
const authProfile = providerAuthProfileFromOpts({ authProfile:
|
|
9899
|
+
const authProfile = providerAuthProfileFromOpts({ authProfile: providerRouting.authProfile }, provider);
|
|
8780
9900
|
const templateId = todosTaskRouteTemplateId(opts);
|
|
8781
9901
|
const workflowInput = {
|
|
8782
9902
|
taskId,
|
|
@@ -8787,13 +9907,13 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
8787
9907
|
projectGroup,
|
|
8788
9908
|
provider,
|
|
8789
9909
|
authProfile,
|
|
8790
|
-
authProfilePool:
|
|
9910
|
+
authProfilePool: providerRouting.authProfilePool,
|
|
8791
9911
|
triageAuthProfile: opts.triageAuthProfile,
|
|
8792
9912
|
plannerAuthProfile: opts.plannerAuthProfile,
|
|
8793
9913
|
workerAuthProfile: opts.workerAuthProfile,
|
|
8794
9914
|
verifierAuthProfile: opts.verifierAuthProfile,
|
|
8795
|
-
account:
|
|
8796
|
-
accountPool:
|
|
9915
|
+
account: providerRouting.account,
|
|
9916
|
+
accountPool: providerRouting.accountPool,
|
|
8797
9917
|
triageAccount: roleAccountFromOpts(opts, opts.triageAccount),
|
|
8798
9918
|
plannerAccount: roleAccountFromOpts(opts, opts.plannerAccount),
|
|
8799
9919
|
workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
|
|
@@ -8803,12 +9923,14 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
8803
9923
|
agent: opts.agent,
|
|
8804
9924
|
addDirs: listFromRepeatedOpts(opts.addDir),
|
|
8805
9925
|
timeoutMs: timeoutDuration(opts.timeout, "--timeout"),
|
|
9926
|
+
verifierIdleTimeoutMs: idleTimeoutDuration(opts.verifierIdleTimeout, "--verifier-idle-timeout"),
|
|
8806
9927
|
permissionMode,
|
|
8807
9928
|
sandbox,
|
|
8808
9929
|
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
8809
9930
|
worktreeMode: opts.worktreeMode ?? "auto",
|
|
8810
9931
|
worktreeRoot: opts.worktreeRoot,
|
|
8811
9932
|
worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops",
|
|
9933
|
+
prHandoff: templateId === TASK_LIFECYCLE_TEMPLATE_ID ? Boolean(opts.prHandoff) : false,
|
|
8812
9934
|
eventId: event.id,
|
|
8813
9935
|
eventType: event.type,
|
|
8814
9936
|
todosProjectPath: opts.todosProject
|
|
@@ -8844,7 +9966,10 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
8844
9966
|
worktreePolicy: opts.worktreeMode ?? "auto",
|
|
8845
9967
|
permissions: permissionMode,
|
|
8846
9968
|
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
8847
|
-
|
|
9969
|
+
prHandoff: templateId === TASK_LIFECYCLE_TEMPLATE_ID ? Boolean(opts.prHandoff) : false,
|
|
9970
|
+
accountPolicy: providerRouting.authProfilePool?.length || providerRouting.accountPool?.length ? "pool" : hasExplicitRoleAccount ? "role-explicit" : "single",
|
|
9971
|
+
providerRouting: providerRoutingPublic(providerRouting),
|
|
9972
|
+
prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
|
|
8848
9973
|
concurrencyGroup: projectGroup ?? routeProjectPath
|
|
8849
9974
|
},
|
|
8850
9975
|
outputPolicy: {
|
|
@@ -8883,7 +10008,7 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
8883
10008
|
}, {}) : undefined;
|
|
8884
10009
|
return {
|
|
8885
10010
|
kind: "created",
|
|
8886
|
-
value: { deduped: false, idempotencyKey, event, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
|
|
10011
|
+
value: { deduped: false, idempotencyKey, event, providerRouting: providerRoutingPublic(providerRouting), prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
|
|
8887
10012
|
human: `dry-run ${loopName}`
|
|
8888
10013
|
};
|
|
8889
10014
|
}
|
|
@@ -8899,8 +10024,8 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
8899
10024
|
const outcome = store.writeTransaction(() => {
|
|
8900
10025
|
const invocation = store.createWorkflowInvocation(invocationInput);
|
|
8901
10026
|
const existingItem = store.findWorkflowWorkItem("todos-task", idempotencyKey);
|
|
8902
|
-
if (existingItem
|
|
8903
|
-
const existingLoop = store.getLoop(existingItem.loopId);
|
|
10027
|
+
if (existingItem && isUnclearedRouteWorkItem(existingItem)) {
|
|
10028
|
+
const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
|
|
8904
10029
|
const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
8905
10030
|
return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
|
|
8906
10031
|
}
|
|
@@ -8911,6 +10036,11 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
8911
10036
|
status: throttle && !throttle.allowed ? "deferred" : "queued",
|
|
8912
10037
|
lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
|
|
8913
10038
|
});
|
|
10039
|
+
const requeue = workItem.attempts > 0 && workItem.status === "queued" ? {
|
|
10040
|
+
previousWorkItemId: workItem.id,
|
|
10041
|
+
previousAttempts: workItem.attempts,
|
|
10042
|
+
reason: workItem.lastReason
|
|
10043
|
+
} : undefined;
|
|
8914
10044
|
const refreshedInvocation = store.refreshWorkflowInvocationForWorkItem(workItem.id, invocationInput);
|
|
8915
10045
|
if (throttle && !throttle.allowed)
|
|
8916
10046
|
return { kind: "throttled", invocation: refreshedInvocation, workItem, throttle };
|
|
@@ -8927,7 +10057,15 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
8927
10057
|
}
|
|
8928
10058
|
});
|
|
8929
10059
|
const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by todos-task route" });
|
|
8930
|
-
return {
|
|
10060
|
+
return {
|
|
10061
|
+
kind: "created",
|
|
10062
|
+
invocation: refreshedInvocation,
|
|
10063
|
+
workItem: admitted,
|
|
10064
|
+
workflow,
|
|
10065
|
+
loop,
|
|
10066
|
+
throttle,
|
|
10067
|
+
requeue: requeue ? { ...requeue, attempt: admitted.attempts, newWorkflowId: workflow.id, newLoopId: loop.id } : undefined
|
|
10068
|
+
};
|
|
8931
10069
|
});
|
|
8932
10070
|
if (outcome.kind === "deduped") {
|
|
8933
10071
|
return {
|
|
@@ -8956,6 +10094,8 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
8956
10094
|
event,
|
|
8957
10095
|
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
8958
10096
|
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
10097
|
+
providerRouting: providerRoutingPublic(providerRouting),
|
|
10098
|
+
prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
|
|
8959
10099
|
throttle: outcome.throttle,
|
|
8960
10100
|
workflow: workflowBody,
|
|
8961
10101
|
loop: loopInput
|
|
@@ -8971,8 +10111,11 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
8971
10111
|
event,
|
|
8972
10112
|
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
8973
10113
|
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
10114
|
+
providerRouting: providerRoutingPublic(providerRouting),
|
|
10115
|
+
prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
|
|
8974
10116
|
workflow: publicWorkflow(outcome.workflow),
|
|
8975
10117
|
loop: publicLoop(outcome.loop),
|
|
10118
|
+
requeue: outcome.requeue,
|
|
8976
10119
|
throttle: outcome.throttle,
|
|
8977
10120
|
sandboxPreflight,
|
|
8978
10121
|
preflight
|
|
@@ -8996,12 +10139,11 @@ function routeGenericEvent(event, opts) {
|
|
|
8996
10139
|
const workflowName = `${opts.namePrefix ?? "event:generic"}:${source}:${type}:${eventSuffix}:workflow`;
|
|
8997
10140
|
const loopName = `${opts.namePrefix ?? "event:generic"}:${source}:${type}:${eventSuffix}:run`;
|
|
8998
10141
|
const idempotencyKey = `generic-event:${event.source}:${event.type}:${event.id}`;
|
|
8999
|
-
const
|
|
9000
|
-
|
|
9001
|
-
throw new Error("unsupported provider");
|
|
10142
|
+
const providerRouting = resolveProviderRouting(data, metadata, opts);
|
|
10143
|
+
const provider = providerRouting.provider;
|
|
9002
10144
|
const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
|
|
9003
10145
|
const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
|
|
9004
|
-
const authProfile = providerAuthProfileFromOpts({ authProfile:
|
|
10146
|
+
const authProfile = providerAuthProfileFromOpts({ authProfile: providerRouting.authProfile }, provider);
|
|
9005
10147
|
let workflowBody = renderEventWorkerVerifierWorkflow({
|
|
9006
10148
|
eventId: event.id,
|
|
9007
10149
|
eventType: event.type,
|
|
@@ -9014,11 +10156,11 @@ function routeGenericEvent(event, opts) {
|
|
|
9014
10156
|
projectGroup,
|
|
9015
10157
|
provider,
|
|
9016
10158
|
authProfile,
|
|
9017
|
-
authProfilePool:
|
|
10159
|
+
authProfilePool: providerRouting.authProfilePool,
|
|
9018
10160
|
workerAuthProfile: opts.workerAuthProfile,
|
|
9019
10161
|
verifierAuthProfile: opts.verifierAuthProfile,
|
|
9020
|
-
account:
|
|
9021
|
-
accountPool:
|
|
10162
|
+
account: providerRouting.account,
|
|
10163
|
+
accountPool: providerRouting.accountPool,
|
|
9022
10164
|
workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
|
|
9023
10165
|
verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
|
|
9024
10166
|
model: opts.model,
|
|
@@ -9026,6 +10168,7 @@ function routeGenericEvent(event, opts) {
|
|
|
9026
10168
|
agent: opts.agent,
|
|
9027
10169
|
addDirs: listFromRepeatedOpts(opts.addDir),
|
|
9028
10170
|
timeoutMs: timeoutDuration(opts.timeout, "--timeout"),
|
|
10171
|
+
verifierIdleTimeoutMs: idleTimeoutDuration(opts.verifierIdleTimeout, "--verifier-idle-timeout"),
|
|
9029
10172
|
permissionMode,
|
|
9030
10173
|
sandbox,
|
|
9031
10174
|
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
@@ -9041,6 +10184,7 @@ function routeGenericEvent(event, opts) {
|
|
|
9041
10184
|
event: event.id
|
|
9042
10185
|
});
|
|
9043
10186
|
const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
|
|
10187
|
+
const hasExplicitRoleAccount = Boolean(opts.workerAuthProfile || opts.verifierAuthProfile || opts.workerAccount || opts.verifierAccount);
|
|
9044
10188
|
const invocationInput = {
|
|
9045
10189
|
templateId: "event-worker-verifier",
|
|
9046
10190
|
sourceRef: {
|
|
@@ -9062,7 +10206,8 @@ function routeGenericEvent(event, opts) {
|
|
|
9062
10206
|
worktreePolicy: opts.worktreeMode ?? "auto",
|
|
9063
10207
|
permissions: permissionMode,
|
|
9064
10208
|
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
9065
|
-
accountPolicy:
|
|
10209
|
+
accountPolicy: providerRouting.authProfilePool?.length || providerRouting.accountPool?.length ? "pool" : hasExplicitRoleAccount ? "role-explicit" : "single",
|
|
10210
|
+
providerRouting: providerRoutingPublic(providerRouting),
|
|
9066
10211
|
concurrencyGroup: projectGroup ?? routeProjectPath
|
|
9067
10212
|
},
|
|
9068
10213
|
outputPolicy: {
|
|
@@ -9101,7 +10246,7 @@ function routeGenericEvent(event, opts) {
|
|
|
9101
10246
|
}, {}) : undefined;
|
|
9102
10247
|
return {
|
|
9103
10248
|
kind: "created",
|
|
9104
|
-
value: { event, idempotencyKey, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
|
|
10249
|
+
value: { event, idempotencyKey, providerRouting: providerRoutingPublic(providerRouting), invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
|
|
9105
10250
|
human: `dry-run ${loopName}`
|
|
9106
10251
|
};
|
|
9107
10252
|
}
|
|
@@ -9117,8 +10262,8 @@ function routeGenericEvent(event, opts) {
|
|
|
9117
10262
|
const outcome = store.writeTransaction(() => {
|
|
9118
10263
|
const invocation = store.createWorkflowInvocation(invocationInput);
|
|
9119
10264
|
const existingItem = store.findWorkflowWorkItem("generic-event", idempotencyKey);
|
|
9120
|
-
if (existingItem
|
|
9121
|
-
const existingLoop = store.getLoop(existingItem.loopId);
|
|
10265
|
+
if (existingItem && isUnclearedRouteWorkItem(existingItem)) {
|
|
10266
|
+
const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
|
|
9122
10267
|
const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
9123
10268
|
return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
|
|
9124
10269
|
}
|
|
@@ -9129,6 +10274,11 @@ function routeGenericEvent(event, opts) {
|
|
|
9129
10274
|
status: throttle && !throttle.allowed ? "deferred" : "queued",
|
|
9130
10275
|
lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
|
|
9131
10276
|
});
|
|
10277
|
+
const requeue = workItem.attempts > 0 && workItem.status === "queued" ? {
|
|
10278
|
+
previousWorkItemId: workItem.id,
|
|
10279
|
+
previousAttempts: workItem.attempts,
|
|
10280
|
+
reason: workItem.lastReason
|
|
10281
|
+
} : undefined;
|
|
9132
10282
|
const refreshedInvocation = store.refreshWorkflowInvocationForWorkItem(workItem.id, invocationInput);
|
|
9133
10283
|
if (throttle && !throttle.allowed)
|
|
9134
10284
|
return { kind: "throttled", invocation: refreshedInvocation, workItem, throttle };
|
|
@@ -9145,7 +10295,15 @@ function routeGenericEvent(event, opts) {
|
|
|
9145
10295
|
}
|
|
9146
10296
|
});
|
|
9147
10297
|
const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by generic-event route" });
|
|
9148
|
-
return {
|
|
10298
|
+
return {
|
|
10299
|
+
kind: "created",
|
|
10300
|
+
invocation: refreshedInvocation,
|
|
10301
|
+
workItem: admitted,
|
|
10302
|
+
workflow,
|
|
10303
|
+
loop,
|
|
10304
|
+
throttle,
|
|
10305
|
+
requeue: requeue ? { ...requeue, attempt: admitted.attempts, newWorkflowId: workflow.id, newLoopId: loop.id } : undefined
|
|
10306
|
+
};
|
|
9149
10307
|
});
|
|
9150
10308
|
if (outcome.kind === "deduped") {
|
|
9151
10309
|
return {
|
|
@@ -9155,6 +10313,7 @@ function routeGenericEvent(event, opts) {
|
|
|
9155
10313
|
idempotencyKey,
|
|
9156
10314
|
dedupedBy: "work-item",
|
|
9157
10315
|
event,
|
|
10316
|
+
providerRouting: providerRoutingPublic(providerRouting),
|
|
9158
10317
|
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
9159
10318
|
workItem: publicWorkflowWorkItem(outcome.existingItem),
|
|
9160
10319
|
workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
|
|
@@ -9172,6 +10331,7 @@ function routeGenericEvent(event, opts) {
|
|
|
9172
10331
|
reason: outcome.throttle.reason,
|
|
9173
10332
|
idempotencyKey,
|
|
9174
10333
|
event,
|
|
10334
|
+
providerRouting: providerRoutingPublic(providerRouting),
|
|
9175
10335
|
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
9176
10336
|
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
9177
10337
|
throttle: outcome.throttle,
|
|
@@ -9187,10 +10347,12 @@ function routeGenericEvent(event, opts) {
|
|
|
9187
10347
|
deduped: false,
|
|
9188
10348
|
idempotencyKey,
|
|
9189
10349
|
event,
|
|
10350
|
+
providerRouting: providerRoutingPublic(providerRouting),
|
|
9190
10351
|
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
9191
10352
|
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
9192
10353
|
workflow: publicWorkflow(outcome.workflow),
|
|
9193
10354
|
loop: publicLoop(outcome.loop),
|
|
10355
|
+
requeue: outcome.requeue,
|
|
9194
10356
|
throttle: outcome.throttle,
|
|
9195
10357
|
sandboxPreflight,
|
|
9196
10358
|
preflight
|
|
@@ -9221,14 +10383,14 @@ function taskDescriptionProjectPath(task) {
|
|
|
9221
10383
|
return match?.[1]?.trim();
|
|
9222
10384
|
}
|
|
9223
10385
|
function taskProjectPath(task) {
|
|
9224
|
-
const metadata =
|
|
10386
|
+
const metadata = objectField2(task.metadata) ?? {};
|
|
9225
10387
|
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"]);
|
|
9226
10388
|
}
|
|
9227
10389
|
function taskDrainEvent(task) {
|
|
9228
10390
|
const taskId = taskField(task, ["id", "task_id", "taskId"]);
|
|
9229
10391
|
if (!taskId)
|
|
9230
10392
|
throw new Error("todos ready returned a task without an id");
|
|
9231
|
-
const metadata =
|
|
10393
|
+
const metadata = objectField2(task.metadata) ?? {};
|
|
9232
10394
|
const workingDir = taskProjectPath(task);
|
|
9233
10395
|
const data = {
|
|
9234
10396
|
...task,
|
|
@@ -9236,7 +10398,7 @@ function taskDrainEvent(task) {
|
|
|
9236
10398
|
title: taskField(task, ["title"]),
|
|
9237
10399
|
description: taskField(task, ["description", "body"]),
|
|
9238
10400
|
status: taskField(task, ["status"]),
|
|
9239
|
-
tags:
|
|
10401
|
+
tags: tagsFromValue2(task.tags),
|
|
9240
10402
|
metadata
|
|
9241
10403
|
};
|
|
9242
10404
|
if (workingDir) {
|
|
@@ -9264,10 +10426,12 @@ function taskDrainEvent(task) {
|
|
|
9264
10426
|
}
|
|
9265
10427
|
function compactDrainResult(result) {
|
|
9266
10428
|
const value = result.value;
|
|
9267
|
-
const event =
|
|
9268
|
-
const loop =
|
|
9269
|
-
const workflow =
|
|
9270
|
-
const throttle =
|
|
10429
|
+
const event = objectField2(value.event);
|
|
10430
|
+
const loop = objectField2(value.loop);
|
|
10431
|
+
const workflow = objectField2(value.workflow);
|
|
10432
|
+
const throttle = objectField2(value.throttle);
|
|
10433
|
+
const requeue = objectField2(value.requeue);
|
|
10434
|
+
const providerRouting = objectField2(value.providerRouting);
|
|
9271
10435
|
return {
|
|
9272
10436
|
kind: result.kind,
|
|
9273
10437
|
taskId: event?.subject,
|
|
@@ -9278,6 +10442,8 @@ function compactDrainResult(result) {
|
|
|
9278
10442
|
loopName: stringField(loop?.name),
|
|
9279
10443
|
workflowId: stringField(workflow?.id),
|
|
9280
10444
|
workflowName: stringField(workflow?.name),
|
|
10445
|
+
providerRouting,
|
|
10446
|
+
requeue,
|
|
9281
10447
|
queuedAtSource: value.queuedAtSource
|
|
9282
10448
|
};
|
|
9283
10449
|
}
|
|
@@ -9324,7 +10490,7 @@ function taskMatchesDrainFilters(task, filters) {
|
|
|
9324
10490
|
return false;
|
|
9325
10491
|
}
|
|
9326
10492
|
if (filters.tags.length) {
|
|
9327
|
-
const taskTags = new Set(
|
|
10493
|
+
const taskTags = new Set(tagsFromValue2(task.tags));
|
|
9328
10494
|
for (const tag of filters.tags) {
|
|
9329
10495
|
if (!taskTags.has(tag))
|
|
9330
10496
|
return false;
|
|
@@ -9456,10 +10622,10 @@ var events = program.command("events").description("handle Hasna event envelopes
|
|
|
9456
10622
|
var machines = program.command("machines").description("inspect OpenMachines topology for loop assignment");
|
|
9457
10623
|
var goal = program.command("goal").description("inspect goal runs");
|
|
9458
10624
|
function addRouteEventOptions(command) {
|
|
9459
|
-
return command.option("--event-file <file>", "read event envelope JSON from a file instead of stdin/HASNA_EVENT_JSON").option("--event-json <json>", "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON").option("--todos-project <path>", "todos storage project path for generated task commands", defaultLoopsProject()).option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider", "
|
|
10625
|
+
return command.option("--event-file <file>", "read event envelope JSON from a file instead of stdin/HASNA_EVENT_JSON").option("--event-json <json>", "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON").option("--todos-project <path>", "todos storage project path for generated task commands", defaultLoopsProject()).option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider; defaults to codewith").option("--provider-rule <rule>", "task metadata provider routing rule field=value:provider[:profile1,profile2]; may be repeated", collectValues, []).option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--triage-auth-profile <profile>", "provider-native auth profile for triage step").option("--planner-auth-profile <profile>", "provider-native auth profile for planner step").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--triage-account <profile>", "OpenAccounts profile for triage step").option("--planner-account <profile>", "OpenAccounts profile for planner step").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--verifier-idle-timeout <duration>", "verifier idle watchdog; use none/off to disable when an external heartbeat exists", "15m").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated worktrees", "openloops").option("--pr-handoff", "for task-lifecycle routes, add a bounded PR handoff step after the worker").option("--github-reviewer <login>", "GitHub login expected to review/merge review-required PR routes; must differ from PR author").option("--github-reviewer-pool <logins>", "comma-separated GitHub logins eligible to review/merge review-required PR routes").option("--name-prefix <prefix>", "workflow/loop name prefix").option("--preflight", "check generated workflow steps before storing the workflow loop");
|
|
9460
10626
|
}
|
|
9461
10627
|
function addTodosDrainOptions(command, opts = {}) {
|
|
9462
|
-
let configured = command.option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider", "
|
|
10628
|
+
let configured = command.option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider; defaults to codewith").option("--provider-rule <rule>", "task metadata provider routing rule field=value:provider[:profile1,profile2]; may be repeated", collectValues, []).option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--triage-auth-profile <profile>", "provider-native auth profile for triage step").option("--planner-auth-profile <profile>", "provider-native auth profile for planner step").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--triage-account <profile>", "OpenAccounts profile for triage step").option("--planner-account <profile>", "OpenAccounts profile for planner step").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--verifier-idle-timeout <duration>", "verifier idle watchdog; use none/off to disable when an external heartbeat exists", "15m").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--pr-handoff", "for task-lifecycle routes, add a bounded PR handoff step after the worker").option("--github-reviewer <login>", "GitHub login expected to review/merge review-required PR routes; must differ from PR author").option("--github-reviewer-pool <logins>", "comma-separated GitHub logins eligible to review/merge review-required PR routes").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", opts.preflightDescription ?? "check generated workflow steps before storing workflow loops");
|
|
9463
10629
|
if (opts.includeDryRun ?? true) {
|
|
9464
10630
|
configured = configured.option("--dry-run", "preview selected tasks and generated workflow loops without storing anything");
|
|
9465
10631
|
}
|
|
@@ -9494,6 +10660,8 @@ function routeDrainArgs(opts) {
|
|
|
9494
10660
|
addBool("--compact", opts.compact);
|
|
9495
10661
|
add("--template", opts.template);
|
|
9496
10662
|
add("--provider", opts.provider);
|
|
10663
|
+
for (const rule of opts.providerRule ?? [])
|
|
10664
|
+
add("--provider-rule", rule);
|
|
9497
10665
|
add("--auth-profile", opts.authProfile);
|
|
9498
10666
|
add("--auth-profile-pool", opts.authProfilePool);
|
|
9499
10667
|
add("--triage-auth-profile", opts.triageAuthProfile);
|
|
@@ -9513,6 +10681,7 @@ function routeDrainArgs(opts) {
|
|
|
9513
10681
|
for (const dir of listFromRepeatedOpts(opts.addDir) ?? [])
|
|
9514
10682
|
add("--add-dir", dir);
|
|
9515
10683
|
add("--timeout", opts.timeout);
|
|
10684
|
+
add("--verifier-idle-timeout", opts.verifierIdleTimeout);
|
|
9516
10685
|
add("--permission-mode", opts.permissionMode);
|
|
9517
10686
|
add("--sandbox", opts.sandbox);
|
|
9518
10687
|
addBool("--manual-break-glass", opts.manualBreakGlass);
|
|
@@ -9524,6 +10693,9 @@ function routeDrainArgs(opts) {
|
|
|
9524
10693
|
add("--worktree-mode", opts.worktreeMode);
|
|
9525
10694
|
add("--worktree-root", opts.worktreeRoot);
|
|
9526
10695
|
add("--worktree-branch-prefix", opts.worktreeBranchPrefix);
|
|
10696
|
+
addBool("--pr-handoff", opts.prHandoff);
|
|
10697
|
+
add("--github-reviewer", opts.githubReviewer);
|
|
10698
|
+
add("--github-reviewer-pool", opts.githubReviewerPool);
|
|
9527
10699
|
add("--name-prefix", opts.namePrefix);
|
|
9528
10700
|
addBool("--preflight", opts.preflight);
|
|
9529
10701
|
return args;
|
|
@@ -9559,7 +10731,8 @@ function drainTodosTaskRoutes(opts) {
|
|
|
9559
10731
|
const message = error instanceof Error ? error.message : String(error);
|
|
9560
10732
|
if (!isSkippableDrainRouteError(message))
|
|
9561
10733
|
throw error;
|
|
9562
|
-
|
|
10734
|
+
const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(todosProject, task, message);
|
|
10735
|
+
result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { sourceTaskUpdate });
|
|
9563
10736
|
}
|
|
9564
10737
|
results.push(result);
|
|
9565
10738
|
if (result.kind === "created" && !opts.dryRun)
|
|
@@ -9770,6 +10943,18 @@ routes.command("show <id>").description("show one admission work item").action((
|
|
|
9770
10943
|
store.close();
|
|
9771
10944
|
}
|
|
9772
10945
|
});
|
|
10946
|
+
routes.command("requeue <id>").description("requeue a terminal admission work item for the next task/event delivery").option("--reason <text>", "operator reason recorded on the work item").action((id, opts) => {
|
|
10947
|
+
const store = new Store;
|
|
10948
|
+
try {
|
|
10949
|
+
const reason = stringField(opts.reason);
|
|
10950
|
+
if (!reason)
|
|
10951
|
+
throw new Error("routes requeue requires --reason <text>");
|
|
10952
|
+
const item = store.requeueWorkflowWorkItem(id, { reason });
|
|
10953
|
+
print(publicWorkflowWorkItem(item), `requeued route work item ${item.id} (${item.routeKey})`);
|
|
10954
|
+
} finally {
|
|
10955
|
+
store.close();
|
|
10956
|
+
}
|
|
10957
|
+
});
|
|
9773
10958
|
routes.command("invocations").description("list workflow invocations").option("--limit <n>", "maximum rows", "50").action((opts) => {
|
|
9774
10959
|
const store = new Store;
|
|
9775
10960
|
try {
|
|
@@ -9824,7 +11009,7 @@ addScheduleOptions(addTodosDrainOptions(routes.command("schedule <kind> <name>")
|
|
|
9824
11009
|
}
|
|
9825
11010
|
});
|
|
9826
11011
|
var eventsHandle = events.command("handle").description("handle a Hasna event envelope");
|
|
9827
|
-
eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--todos-project <path>", "todos storage project path for generated task commands", defaultLoopsProject()).option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider", "
|
|
11012
|
+
eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--todos-project <path>", "todos storage project path for generated task commands", defaultLoopsProject()).option("--template <id>", "todos-task route workflow template: todos-task-worker-verifier or task-lifecycle", TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).option("--provider <provider>", "agent provider; defaults to codewith").option("--provider-rule <rule>", "task metadata provider routing rule field=value:provider[:profile1,profile2]; may be repeated", collectValues, []).option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--triage-auth-profile <profile>", "provider-native auth profile for triage step").option("--planner-auth-profile <profile>", "provider-native auth profile for planner step").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--triage-account <profile>", "OpenAccounts profile for triage step").option("--planner-account <profile>", "OpenAccounts profile for planner step").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--verifier-idle-timeout <duration>", "verifier idle watchdog; use none/off to disable when an external heartbeat exists", "15m").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--github-reviewer <login>", "GitHub login expected to review/merge review-required PR routes; must differ from PR author").option("--github-reviewer-pool <logins>", "comma-separated GitHub logins eligible to review/merge review-required PR routes").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
|
|
9828
11013
|
const event = await readEventEnvelopeFromStdin();
|
|
9829
11014
|
const result = routeTodosTaskEvent(event, opts);
|
|
9830
11015
|
print(result.value, result.human);
|
|
@@ -9833,7 +11018,7 @@ var eventsDrain = events.command("drain").description("drain durable source queu
|
|
|
9833
11018
|
addTodosDrainOptions(eventsDrain.command("todos-task").description("drain ready todos tasks into bounded worker/verifier workflow loops")).action((opts) => {
|
|
9834
11019
|
drainTodosTaskRoutes(opts);
|
|
9835
11020
|
});
|
|
9836
|
-
eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated event worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
|
|
11021
|
+
eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--provider-rule <rule>", "event metadata provider routing rule field=value:provider[:profile1,profile2]; may be repeated", collectValues, []).option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--timeout <duration>", "agent step timeout; use none/unlimited for no timeout").option("--verifier-idle-timeout <duration>", "verifier idle watchdog; use none/off to disable when an external heartbeat exists", "15m").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated event worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
|
|
9837
11022
|
const event = await readEventEnvelopeFromStdin();
|
|
9838
11023
|
const result = routeGenericEvent(event, opts);
|
|
9839
11024
|
print(result.value, result.human);
|
|
@@ -10118,6 +11303,66 @@ workflows.command("migrate-agent-timeouts").description("append-only migrate act
|
|
|
10118
11303
|
store.close();
|
|
10119
11304
|
}
|
|
10120
11305
|
});
|
|
11306
|
+
workflows.command("migrate-goal-wrappers").description("append-only migrate active workflow loops away from workflow-level goal wrappers").option("--loop <idOrName>", "migrate only one loop instead of all active workflow loops").option("--apply", "create new workflow specs and retarget eligible loops").option("--archive-old", "archive old workflow specs after retargeting when no active loops still reference them").action((opts) => {
|
|
11307
|
+
const store = new Store;
|
|
11308
|
+
try {
|
|
11309
|
+
const candidateLoops = opts.loop ? [store.requireUniqueLoop(opts.loop)] : store.listLoops({ status: "active", limit: 1e4 }).filter((loop) => loop.target.type === "workflow");
|
|
11310
|
+
const rows = [];
|
|
11311
|
+
for (const loop of candidateLoops) {
|
|
11312
|
+
if (loop.archivedAt) {
|
|
11313
|
+
rows.push({ loop: publicLoop(loop), status: "skipped", reason: "loop is archived" });
|
|
11314
|
+
continue;
|
|
11315
|
+
}
|
|
11316
|
+
if (loop.target.type !== "workflow") {
|
|
11317
|
+
rows.push({ loop: publicLoop(loop), status: "skipped", reason: "loop is not a workflow loop" });
|
|
11318
|
+
continue;
|
|
11319
|
+
}
|
|
11320
|
+
const workflow = store.requireWorkflow(loop.target.workflowId);
|
|
11321
|
+
const nextWorkflowName = workflowGoalWrapperMigrationName(workflow);
|
|
11322
|
+
const migration = workflowWithoutGoalWrapper(workflow, { name: nextWorkflowName });
|
|
11323
|
+
if (!migration.changed) {
|
|
11324
|
+
rows.push({ loop: publicLoop(loop), workflow: publicWorkflow(workflow), status: "skipped", reason: "workflow has no top-level goal wrapper" });
|
|
11325
|
+
continue;
|
|
11326
|
+
}
|
|
11327
|
+
if (store.hasRunningRun(loop.id)) {
|
|
11328
|
+
rows.push({ loop: publicLoop(loop), workflow: publicWorkflow(workflow), status: "blocked", reason: "loop has a running run; retry after it finishes" });
|
|
11329
|
+
continue;
|
|
11330
|
+
}
|
|
11331
|
+
if (!opts.apply) {
|
|
11332
|
+
rows.push({
|
|
11333
|
+
loop: publicLoop(loop),
|
|
11334
|
+
workflow: publicWorkflow(workflow),
|
|
11335
|
+
status: "would_migrate",
|
|
11336
|
+
nextWorkflowName,
|
|
11337
|
+
removedGoal: workflow.goal
|
|
11338
|
+
});
|
|
11339
|
+
continue;
|
|
11340
|
+
}
|
|
11341
|
+
const migrated = store.createAndRetargetWorkflowLoop(loop.id, migration.body, {
|
|
11342
|
+
workflowTimeoutMs: loop.target.timeoutMs,
|
|
11343
|
+
archiveOld: Boolean(opts.archiveOld)
|
|
11344
|
+
});
|
|
11345
|
+
rows.push({
|
|
11346
|
+
loop: publicLoop(migrated.loop),
|
|
11347
|
+
previousWorkflow: publicWorkflow(migrated.previousWorkflow),
|
|
11348
|
+
workflow: publicWorkflow(migrated.workflow),
|
|
11349
|
+
archivedOld: migrated.archivedOld ? publicWorkflow(migrated.archivedOld) : undefined,
|
|
11350
|
+
status: "migrated"
|
|
11351
|
+
});
|
|
11352
|
+
}
|
|
11353
|
+
const summary = {
|
|
11354
|
+
apply: Boolean(opts.apply),
|
|
11355
|
+
total: rows.length,
|
|
11356
|
+
migrated: rows.filter((row) => row.status === "migrated").length,
|
|
11357
|
+
wouldMigrate: rows.filter((row) => row.status === "would_migrate").length,
|
|
11358
|
+
blocked: rows.filter((row) => row.status === "blocked").length,
|
|
11359
|
+
skipped: rows.filter((row) => row.status === "skipped").length
|
|
11360
|
+
};
|
|
11361
|
+
print({ summary, rows }, opts.apply ? `migrated=${summary.migrated} blocked=${summary.blocked} skipped=${summary.skipped}` : `would_migrate=${summary.wouldMigrate} blocked=${summary.blocked} skipped=${summary.skipped}`);
|
|
11362
|
+
} finally {
|
|
11363
|
+
store.close();
|
|
11364
|
+
}
|
|
11365
|
+
});
|
|
10121
11366
|
workflows.command("archive <idOrName>").action((idOrName) => {
|
|
10122
11367
|
const store = new Store;
|
|
10123
11368
|
try {
|
|
@@ -10139,7 +11384,7 @@ program.command("list").alias("ls").option("--status <status>", "filter by statu
|
|
|
10139
11384
|
for (const loop of loops) {
|
|
10140
11385
|
const machine = loop.machine ? ` machine=${loop.machine.id}` : "";
|
|
10141
11386
|
const archive = loop.archivedAt ? ` archived=${loop.archivedAt} from=${loop.archivedFromStatus ?? "-"}` : "";
|
|
10142
|
-
console.log(`${loop.id} ${loop.status.padEnd(7)} next=${loop.nextRunAt ?? "-"} ${loop.name}${machine}${archive}`);
|
|
11387
|
+
console.log(`${loop.id} ${loop.status.padEnd(7)} cadence=${scheduleLabel(loop.schedule)} next=${loop.nextRunAt ?? "-"} ${loop.name}${machine}${archive}`);
|
|
10143
11388
|
}
|
|
10144
11389
|
}
|
|
10145
11390
|
} finally {
|
|
@@ -10779,11 +12024,11 @@ ${result.instructions.join(`
|
|
|
10779
12024
|
});
|
|
10780
12025
|
daemon.command("logs").option("-n, --lines <n>", "lines", "80").action((opts) => {
|
|
10781
12026
|
const path = daemonLogPath();
|
|
10782
|
-
if (!
|
|
12027
|
+
if (!existsSync6(path)) {
|
|
10783
12028
|
console.log("");
|
|
10784
12029
|
return;
|
|
10785
12030
|
}
|
|
10786
|
-
const lines =
|
|
12031
|
+
const lines = readFileSync5(path, "utf8").trimEnd().split(`
|
|
10787
12032
|
`);
|
|
10788
12033
|
console.log(lines.slice(-Number(opts.lines)).join(`
|
|
10789
12034
|
`));
|