@hasna/loops 0.3.54 → 0.3.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/store.js CHANGED
@@ -329,6 +329,15 @@ function optionalPositiveInteger(value, label) {
329
329
  throw new Error(`${label} must be a positive integer`);
330
330
  return value;
331
331
  }
332
+ function optionalTimeoutMs(value, label) {
333
+ if (value === undefined)
334
+ return;
335
+ if (value === null)
336
+ return null;
337
+ if (!Number.isInteger(value) || value <= 0)
338
+ throw new Error(`${label} must be a positive integer or null for unlimited`);
339
+ return value;
340
+ }
332
341
  function optionalBoolean(value, label) {
333
342
  if (value === undefined)
334
343
  return;
@@ -417,7 +426,7 @@ function validateTarget(value, label, opts) {
417
426
  assertObject(value, label);
418
427
  if (value.type === "command") {
419
428
  assertString(value.command, `${label}.command`);
420
- optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
429
+ optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
421
430
  optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
422
431
  if (value.shell !== true && /\s/.test(value.command.trim())) {
423
432
  throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
@@ -436,7 +445,7 @@ function validateTarget(value, label, opts) {
436
445
  const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
437
446
  if (!providers.includes(value.provider))
438
447
  throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
439
- optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
448
+ optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
440
449
  optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
441
450
  if (value.authProfile !== undefined) {
442
451
  assertString(value.authProfile, `${label}.authProfile`);
@@ -559,7 +568,7 @@ function normalizeCreateWorkflowInput(input, opts = {}) {
559
568
  target: validateTarget(step.target, `workflow.steps[${index}].target`, opts),
560
569
  dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
561
570
  continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
562
- timeoutMs: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
571
+ timeoutMs: optionalTimeoutMs(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
563
572
  account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
564
573
  };
565
574
  });
@@ -1305,6 +1314,17 @@ class Store {
1305
1314
  const row = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 1").get(name);
1306
1315
  return row ? rowToLoop(row) : undefined;
1307
1316
  }
1317
+ requireUniqueLoop(idOrName) {
1318
+ const byId = this.getLoop(idOrName);
1319
+ if (byId)
1320
+ return byId;
1321
+ const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
1322
+ if (rows.length === 0)
1323
+ throw new Error(`loop not found: ${idOrName}`);
1324
+ if (rows.length > 1)
1325
+ throw new Error(`ambiguous loop name: ${idOrName}; use a loop id`);
1326
+ return rowToLoop(rows[0]);
1327
+ }
1308
1328
  requireLoop(idOrName) {
1309
1329
  return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
1310
1330
  throw new Error(`loop not found: ${idOrName}`);
@@ -1379,6 +1399,130 @@ class Store {
1379
1399
  throw new Error(`loop not found after update: ${id}`);
1380
1400
  return after;
1381
1401
  }
1402
+ activeLoopReferenceCount(workflowId) {
1403
+ const rows = this.db.query("SELECT target_json FROM loops WHERE archived_at IS NULL").all();
1404
+ let count = 0;
1405
+ for (const row of rows) {
1406
+ try {
1407
+ const target = JSON.parse(row.target_json);
1408
+ if (target.type === "workflow" && target.workflowId === workflowId)
1409
+ count += 1;
1410
+ } catch {}
1411
+ }
1412
+ return count;
1413
+ }
1414
+ archiveWorkflowIfUnreferenced(workflowId, updated) {
1415
+ if (this.activeLoopReferenceCount(workflowId) > 0)
1416
+ return;
1417
+ const workflow = this.getWorkflow(workflowId);
1418
+ if (!workflow || workflow.status !== "active")
1419
+ return;
1420
+ const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(updated, workflowId);
1421
+ if (res.changes !== 1)
1422
+ return;
1423
+ return this.getWorkflow(workflowId);
1424
+ }
1425
+ retargetWorkflowLoop(idOrName, workflowId, opts = {}) {
1426
+ const updated = (opts.now ?? new Date).toISOString();
1427
+ this.db.exec("BEGIN IMMEDIATE");
1428
+ try {
1429
+ const current = this.requireLoop(idOrName);
1430
+ if (current.target.type !== "workflow")
1431
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1432
+ if (this.hasRunningRun(current.id))
1433
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1434
+ const workflow = this.requireWorkflow(workflowId);
1435
+ const target = { ...current.target, workflowId: workflow.id };
1436
+ if (opts.workflowTimeoutMs !== undefined)
1437
+ target.timeoutMs = opts.workflowTimeoutMs;
1438
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1439
+ WHERE id=$id
1440
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1441
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1442
+ ))`).run({
1443
+ $id: current.id,
1444
+ $target: JSON.stringify(target),
1445
+ $updated: updated,
1446
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1447
+ $now: updated
1448
+ });
1449
+ if (res.changes !== 1)
1450
+ throw new Error("daemon lease lost");
1451
+ this.db.exec("COMMIT");
1452
+ const after = this.getLoop(current.id);
1453
+ if (!after)
1454
+ throw new Error(`loop not found after retarget: ${current.id}`);
1455
+ return after;
1456
+ } catch (error) {
1457
+ try {
1458
+ this.db.exec("ROLLBACK");
1459
+ } catch {}
1460
+ throw error;
1461
+ }
1462
+ }
1463
+ createAndRetargetWorkflowLoop(idOrName, workflowInput, opts = {}) {
1464
+ const normalized = normalizeCreateWorkflowInput(workflowInput);
1465
+ const updated = (opts.now ?? new Date).toISOString();
1466
+ this.db.exec("BEGIN IMMEDIATE");
1467
+ try {
1468
+ const current = this.requireUniqueLoop(idOrName);
1469
+ if (current.target.type !== "workflow")
1470
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1471
+ if (this.hasRunningRun(current.id))
1472
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1473
+ const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1474
+ const workflow = {
1475
+ id: genId(),
1476
+ name: normalized.name,
1477
+ description: normalized.description,
1478
+ version: normalized.version ?? 1,
1479
+ status: "active",
1480
+ goal: normalized.goal,
1481
+ steps: normalized.steps,
1482
+ createdAt: updated,
1483
+ updatedAt: updated
1484
+ };
1485
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
1486
+ VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)`).run({
1487
+ $id: workflow.id,
1488
+ $name: workflow.name,
1489
+ $description: workflow.description ?? null,
1490
+ $version: workflow.version,
1491
+ $status: workflow.status,
1492
+ $goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
1493
+ $steps: JSON.stringify(workflow.steps),
1494
+ $created: workflow.createdAt,
1495
+ $updated: workflow.updatedAt
1496
+ });
1497
+ const target = { ...current.target, workflowId: workflow.id };
1498
+ if (opts.workflowTimeoutMs !== undefined)
1499
+ target.timeoutMs = opts.workflowTimeoutMs;
1500
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1501
+ WHERE id=$id
1502
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1503
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1504
+ ))`).run({
1505
+ $id: current.id,
1506
+ $target: JSON.stringify(target),
1507
+ $updated: updated,
1508
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1509
+ $now: updated
1510
+ });
1511
+ if (res.changes !== 1)
1512
+ throw new Error("daemon lease lost");
1513
+ const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
1514
+ this.db.exec("COMMIT");
1515
+ const loop = this.getLoop(current.id);
1516
+ if (!loop)
1517
+ throw new Error(`loop not found after retarget: ${current.id}`);
1518
+ return { loop, workflow, previousWorkflow, archivedOld };
1519
+ } catch (error) {
1520
+ try {
1521
+ this.db.exec("ROLLBACK");
1522
+ } catch {}
1523
+ throw error;
1524
+ }
1525
+ }
1382
1526
  renameLoop(id, name, opts = {}) {
1383
1527
  const current = this.getLoop(id);
1384
1528
  if (!current)
@@ -1,4 +1,4 @@
1
- import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, AgentWorktreeMode, CreateWorkflowInput, LoopTemplateSource, LoopTemplateSummary } from "../types.js";
1
+ import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, AgentWorktreeMode, CreateWorkflowInput, LoopTemplateSource, LoopTemplateSummary, TimeoutMs } from "../types.js";
2
2
  export declare const TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
3
3
  export declare const EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
4
4
  export declare const BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
@@ -40,6 +40,7 @@ export interface TodosTaskWorkflowTemplateInput {
40
40
  worktreeMode?: AgentWorktreeMode;
41
41
  worktreeRoot?: string;
42
42
  worktreeBranchPrefix?: string;
43
+ timeoutMs?: TimeoutMs;
43
44
  eventId?: string;
44
45
  eventType?: string;
45
46
  }
@@ -76,6 +77,7 @@ export interface EventWorkflowTemplateInput {
76
77
  worktreeMode?: AgentWorktreeMode;
77
78
  worktreeRoot?: string;
78
79
  worktreeBranchPrefix?: string;
80
+ timeoutMs?: TimeoutMs;
79
81
  }
80
82
  export interface BoundedAgentWorkflowTemplateInput {
81
83
  name?: string;
@@ -107,7 +109,7 @@ export interface BoundedAgentWorkflowTemplateInput {
107
109
  worktreeMode?: AgentWorktreeMode;
108
110
  worktreeRoot?: string;
109
111
  worktreeBranchPrefix?: string;
110
- timeoutMs?: number;
112
+ timeoutMs?: TimeoutMs;
111
113
  }
112
114
  export type LoopTemplateSourceFilter = LoopTemplateSource | "all";
113
115
  export interface ListLoopTemplatesOptions {
package/dist/sdk/index.js CHANGED
@@ -329,6 +329,15 @@ function optionalPositiveInteger(value, label) {
329
329
  throw new Error(`${label} must be a positive integer`);
330
330
  return value;
331
331
  }
332
+ function optionalTimeoutMs(value, label) {
333
+ if (value === undefined)
334
+ return;
335
+ if (value === null)
336
+ return null;
337
+ if (!Number.isInteger(value) || value <= 0)
338
+ throw new Error(`${label} must be a positive integer or null for unlimited`);
339
+ return value;
340
+ }
332
341
  function optionalBoolean(value, label) {
333
342
  if (value === undefined)
334
343
  return;
@@ -417,7 +426,7 @@ function validateTarget(value, label, opts) {
417
426
  assertObject(value, label);
418
427
  if (value.type === "command") {
419
428
  assertString(value.command, `${label}.command`);
420
- optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
429
+ optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
421
430
  optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
422
431
  if (value.shell !== true && /\s/.test(value.command.trim())) {
423
432
  throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
@@ -436,7 +445,7 @@ function validateTarget(value, label, opts) {
436
445
  const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
437
446
  if (!providers.includes(value.provider))
438
447
  throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
439
- optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
448
+ optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
440
449
  optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
441
450
  if (value.authProfile !== undefined) {
442
451
  assertString(value.authProfile, `${label}.authProfile`);
@@ -559,7 +568,7 @@ function normalizeCreateWorkflowInput(input, opts = {}) {
559
568
  target: validateTarget(step.target, `workflow.steps[${index}].target`, opts),
560
569
  dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
561
570
  continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
562
- timeoutMs: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
571
+ timeoutMs: optionalTimeoutMs(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
563
572
  account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
564
573
  };
565
574
  });
@@ -1305,6 +1314,17 @@ class Store {
1305
1314
  const row = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 1").get(name);
1306
1315
  return row ? rowToLoop(row) : undefined;
1307
1316
  }
1317
+ requireUniqueLoop(idOrName) {
1318
+ const byId = this.getLoop(idOrName);
1319
+ if (byId)
1320
+ return byId;
1321
+ const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
1322
+ if (rows.length === 0)
1323
+ throw new Error(`loop not found: ${idOrName}`);
1324
+ if (rows.length > 1)
1325
+ throw new Error(`ambiguous loop name: ${idOrName}; use a loop id`);
1326
+ return rowToLoop(rows[0]);
1327
+ }
1308
1328
  requireLoop(idOrName) {
1309
1329
  return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
1310
1330
  throw new Error(`loop not found: ${idOrName}`);
@@ -1379,6 +1399,130 @@ class Store {
1379
1399
  throw new Error(`loop not found after update: ${id}`);
1380
1400
  return after;
1381
1401
  }
1402
+ activeLoopReferenceCount(workflowId) {
1403
+ const rows = this.db.query("SELECT target_json FROM loops WHERE archived_at IS NULL").all();
1404
+ let count = 0;
1405
+ for (const row of rows) {
1406
+ try {
1407
+ const target = JSON.parse(row.target_json);
1408
+ if (target.type === "workflow" && target.workflowId === workflowId)
1409
+ count += 1;
1410
+ } catch {}
1411
+ }
1412
+ return count;
1413
+ }
1414
+ archiveWorkflowIfUnreferenced(workflowId, updated) {
1415
+ if (this.activeLoopReferenceCount(workflowId) > 0)
1416
+ return;
1417
+ const workflow = this.getWorkflow(workflowId);
1418
+ if (!workflow || workflow.status !== "active")
1419
+ return;
1420
+ const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(updated, workflowId);
1421
+ if (res.changes !== 1)
1422
+ return;
1423
+ return this.getWorkflow(workflowId);
1424
+ }
1425
+ retargetWorkflowLoop(idOrName, workflowId, opts = {}) {
1426
+ const updated = (opts.now ?? new Date).toISOString();
1427
+ this.db.exec("BEGIN IMMEDIATE");
1428
+ try {
1429
+ const current = this.requireLoop(idOrName);
1430
+ if (current.target.type !== "workflow")
1431
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1432
+ if (this.hasRunningRun(current.id))
1433
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1434
+ const workflow = this.requireWorkflow(workflowId);
1435
+ const target = { ...current.target, workflowId: workflow.id };
1436
+ if (opts.workflowTimeoutMs !== undefined)
1437
+ target.timeoutMs = opts.workflowTimeoutMs;
1438
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1439
+ WHERE id=$id
1440
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1441
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1442
+ ))`).run({
1443
+ $id: current.id,
1444
+ $target: JSON.stringify(target),
1445
+ $updated: updated,
1446
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1447
+ $now: updated
1448
+ });
1449
+ if (res.changes !== 1)
1450
+ throw new Error("daemon lease lost");
1451
+ this.db.exec("COMMIT");
1452
+ const after = this.getLoop(current.id);
1453
+ if (!after)
1454
+ throw new Error(`loop not found after retarget: ${current.id}`);
1455
+ return after;
1456
+ } catch (error) {
1457
+ try {
1458
+ this.db.exec("ROLLBACK");
1459
+ } catch {}
1460
+ throw error;
1461
+ }
1462
+ }
1463
+ createAndRetargetWorkflowLoop(idOrName, workflowInput, opts = {}) {
1464
+ const normalized = normalizeCreateWorkflowInput(workflowInput);
1465
+ const updated = (opts.now ?? new Date).toISOString();
1466
+ this.db.exec("BEGIN IMMEDIATE");
1467
+ try {
1468
+ const current = this.requireUniqueLoop(idOrName);
1469
+ if (current.target.type !== "workflow")
1470
+ throw new Error(`loop is not a workflow loop: ${idOrName}`);
1471
+ if (this.hasRunningRun(current.id))
1472
+ throw new Error(`refusing to retarget running loop: ${current.id}`);
1473
+ const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1474
+ const workflow = {
1475
+ id: genId(),
1476
+ name: normalized.name,
1477
+ description: normalized.description,
1478
+ version: normalized.version ?? 1,
1479
+ status: "active",
1480
+ goal: normalized.goal,
1481
+ steps: normalized.steps,
1482
+ createdAt: updated,
1483
+ updatedAt: updated
1484
+ };
1485
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
1486
+ VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)`).run({
1487
+ $id: workflow.id,
1488
+ $name: workflow.name,
1489
+ $description: workflow.description ?? null,
1490
+ $version: workflow.version,
1491
+ $status: workflow.status,
1492
+ $goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
1493
+ $steps: JSON.stringify(workflow.steps),
1494
+ $created: workflow.createdAt,
1495
+ $updated: workflow.updatedAt
1496
+ });
1497
+ const target = { ...current.target, workflowId: workflow.id };
1498
+ if (opts.workflowTimeoutMs !== undefined)
1499
+ target.timeoutMs = opts.workflowTimeoutMs;
1500
+ const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
1501
+ WHERE id=$id
1502
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1503
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1504
+ ))`).run({
1505
+ $id: current.id,
1506
+ $target: JSON.stringify(target),
1507
+ $updated: updated,
1508
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1509
+ $now: updated
1510
+ });
1511
+ if (res.changes !== 1)
1512
+ throw new Error("daemon lease lost");
1513
+ const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
1514
+ this.db.exec("COMMIT");
1515
+ const loop = this.getLoop(current.id);
1516
+ if (!loop)
1517
+ throw new Error(`loop not found after retarget: ${current.id}`);
1518
+ return { loop, workflow, previousWorkflow, archivedOld };
1519
+ } catch (error) {
1520
+ try {
1521
+ this.db.exec("ROLLBACK");
1522
+ } catch {}
1523
+ throw error;
1524
+ }
1525
+ }
1382
1526
  renameLoop(id, name, opts = {}) {
1383
1527
  const current = this.getLoop(id);
1384
1528
  if (!current)
@@ -3480,7 +3624,7 @@ function commandSpec(target) {
3480
3624
  cwd: commandTarget.cwd,
3481
3625
  shell: commandTarget.shell,
3482
3626
  env: commandTarget.env,
3483
- timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3627
+ timeoutMs: commandTarget.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : commandTarget.timeoutMs,
3484
3628
  idleTimeoutMs: commandTarget.idleTimeoutMs,
3485
3629
  account: commandTarget.account,
3486
3630
  accountTool: commandTarget.account?.tool
@@ -3491,7 +3635,7 @@ function commandSpec(target) {
3491
3635
  command: providerCommand(agentTarget.provider),
3492
3636
  args: agentArgs(agentTarget),
3493
3637
  cwd: agentTarget.cwd,
3494
- timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3638
+ timeoutMs: agentTarget.timeoutMs ?? null,
3495
3639
  idleTimeoutMs: agentTarget.idleTimeoutMs,
3496
3640
  account: agentTarget.account,
3497
3641
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
@@ -3679,12 +3823,12 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
3679
3823
  if (opts.signal?.aborted)
3680
3824
  abortHandler();
3681
3825
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
3682
- const timer = setTimeout(() => {
3826
+ const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
3683
3827
  timedOut = true;
3684
3828
  if (child.pid)
3685
3829
  killProcessGroup(child.pid);
3686
- }, spec.timeoutMs);
3687
- timer.unref();
3830
+ }, spec.timeoutMs) : undefined;
3831
+ timer?.unref();
3688
3832
  let idleTimer;
3689
3833
  const resetIdleTimer = () => {
3690
3834
  if (!spec.idleTimeoutMs)
@@ -3716,7 +3860,8 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
3716
3860
  } catch (err) {
3717
3861
  error = err instanceof Error ? err.message : String(err);
3718
3862
  } finally {
3719
- clearTimeout(timer);
3863
+ if (timer)
3864
+ clearTimeout(timer);
3720
3865
  if (idleTimer)
3721
3866
  clearTimeout(idleTimer);
3722
3867
  opts.signal?.removeEventListener("abort", abortHandler);
@@ -3858,12 +4003,12 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3858
4003
  if (opts.signal?.aborted)
3859
4004
  abortHandler();
3860
4005
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
3861
- const timer = setTimeout(() => {
4006
+ const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
3862
4007
  timedOut = true;
3863
4008
  if (child.pid)
3864
4009
  killProcessGroup(child.pid);
3865
- }, spec.timeoutMs);
3866
- timer.unref();
4010
+ }, spec.timeoutMs) : undefined;
4011
+ timer?.unref();
3867
4012
  let idleTimer;
3868
4013
  const resetIdleTimer = () => {
3869
4014
  if (!spec.idleTimeoutMs)
@@ -3895,7 +4040,8 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3895
4040
  } catch (err) {
3896
4041
  error = err instanceof Error ? err.message : String(err);
3897
4042
  } finally {
3898
- clearTimeout(timer);
4043
+ if (timer)
4044
+ clearTimeout(timer);
3899
4045
  if (idleTimer)
3900
4046
  clearTimeout(idleTimer);
3901
4047
  opts.signal?.removeEventListener("abort", abortHandler);
@@ -4320,7 +4466,7 @@ ${result.stderr}`);
4320
4466
  // src/lib/workflow-runner.ts
4321
4467
  function targetWithStepAccount(step) {
4322
4468
  const account = step.account ?? step.target.account;
4323
- const timeoutMs = step.timeoutMs ?? step.target.timeoutMs;
4469
+ const timeoutMs = step.timeoutMs !== undefined ? step.timeoutMs : step.target.timeoutMs;
4324
4470
  if (!account && timeoutMs === step.target.timeoutMs)
4325
4471
  return step.target;
4326
4472
  return { ...step.target, account, timeoutMs };
@@ -4616,7 +4762,8 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4616
4762
  })
4617
4763
  });
4618
4764
  }
4619
- const controller = loop.target.timeoutMs ? new AbortController : undefined;
4765
+ const workflowTimeoutMs = typeof loop.target.timeoutMs === "number" ? loop.target.timeoutMs : undefined;
4766
+ const controller = workflowTimeoutMs !== undefined ? new AbortController : undefined;
4620
4767
  let workflowTimedOut = false;
4621
4768
  const externalAbort = () => controller?.abort();
4622
4769
  if (controller && opts.signal?.aborted)
@@ -4626,13 +4773,13 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4626
4773
  const timer = controller ? setTimeout(() => {
4627
4774
  workflowTimedOut = true;
4628
4775
  controller.abort();
4629
- }, loop.target.timeoutMs) : undefined;
4776
+ }, workflowTimeoutMs) : undefined;
4630
4777
  timer?.unref();
4631
4778
  try {
4632
4779
  return await executeWorkflow(store, workflow, {
4633
4780
  ...opts,
4634
4781
  signal: controller?.signal ?? opts.signal,
4635
- signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${loop.target.timeoutMs}ms` : undefined,
4782
+ signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${workflowTimeoutMs}ms` : undefined,
4636
4783
  loop,
4637
4784
  loopRun: run,
4638
4785
  scheduledFor: run.scheduledFor,
package/dist/types.d.ts CHANGED
@@ -70,11 +70,12 @@ export interface CommandTarget {
70
70
  cwd?: string;
71
71
  shell?: boolean;
72
72
  env?: Record<string, string>;
73
- timeoutMs?: number;
73
+ timeoutMs?: TimeoutMs;
74
74
  idleTimeoutMs?: number;
75
75
  account?: AccountRef;
76
76
  preflight?: RuntimePreflightPolicy;
77
77
  }
78
+ export type TimeoutMs = number | null;
78
79
  export type AgentProvider = "claude" | "cursor" | "codewith" | "aicopilot" | "opencode" | "codex";
79
80
  export type AgentConfigIsolation = "safe" | "none";
80
81
  export type AgentPermissionMode = "default" | "plan" | "auto" | "bypass";
@@ -118,7 +119,7 @@ export interface AgentTargetBase {
118
119
  authProfile?: string;
119
120
  extraArgs?: string[];
120
121
  addDirs?: string[];
121
- timeoutMs?: number;
122
+ timeoutMs?: TimeoutMs;
122
123
  idleTimeoutMs?: number;
123
124
  configIsolation?: AgentConfigIsolation;
124
125
  permissionMode?: AgentPermissionMode;
@@ -140,7 +141,7 @@ export interface WorkflowTarget {
140
141
  type: "workflow";
141
142
  workflowId: string;
142
143
  input?: Record<string, string>;
143
- timeoutMs?: number;
144
+ timeoutMs?: TimeoutMs;
144
145
  preflight?: RuntimePreflightPolicy;
145
146
  }
146
147
  export type ExecutableTarget = CommandTarget | AgentTarget;
@@ -242,7 +243,7 @@ export interface WorkflowStep {
242
243
  goal?: GoalSpec;
243
244
  dependsOn?: string[];
244
245
  continueOnFailure?: boolean;
245
- timeoutMs?: number;
246
+ timeoutMs?: TimeoutMs;
246
247
  account?: AccountRef;
247
248
  }
248
249
  export interface WorkflowStepInput extends Omit<WorkflowStep, "target"> {
package/docs/USAGE.md CHANGED
@@ -245,6 +245,11 @@ loops workflows recover <workflow-run-id>
245
245
  loops create workflow repo-morning-loop --workflow repo-morning --cron "0 8 * * *"
246
246
  ```
247
247
 
248
+ Use `recover` only for interrupted `running` workflow runs whose recorded child
249
+ process is gone. Terminal `timed_out` task/event workflow runs are audit
250
+ history; requeue them through the original task/event route after fixing the
251
+ cause.
252
+
248
253
  Workflow specs are stored separately from loops. A loop can schedule a workflow, but workflow runs and step runs have their own durable rows and events. Steps run in dependency order and a scheduled workflow run is idempotent per loop slot.
249
254
 
250
255
  For command steps, `command` is the executable when `shell` is not true. Put flags in `args`:
@@ -310,6 +315,30 @@ Custom reusable workflow templates live under the OpenLoops app data directory:
310
315
  showing, and rendering templates never executes workflow steps or mutates the
311
316
  registry.
312
317
 
318
+ Timeout policy is explicit. Deterministic command/check steps should normally
319
+ keep finite `timeoutMs`/`idleTimeoutMs` guards so broken shell work cannot run
320
+ forever. Agentic work steps default to no timeout in built-in worker/verifier
321
+ and task-lifecycle templates; use `timeoutMs: null` in workflow JSON, or
322
+ `--timeout none` / `--timeout unlimited` for CLI-created targets, when a step
323
+ may need hours or days. Use a positive numeric `timeoutMs` only when an agentic
324
+ step is intentionally bounded.
325
+
326
+ To migrate existing workflow loops, do not edit `workflow_specs.steps_json`
327
+ directly because historical workflow runs must keep pointing at their original
328
+ spec. Use the append-only migrator:
329
+
330
+ ```bash
331
+ loops workflows migrate-agent-timeouts --loop <loop-id-or-name>
332
+ loops workflows migrate-agent-timeouts --loop <loop-id-or-name> --apply
333
+ ```
334
+
335
+ The command dry-runs by default. With `--apply`, it creates a new workflow spec
336
+ with the requested agent timeout policy, retargets only future executions of
337
+ eligible non-running workflow loops, and leaves terminal timed-out workflow runs
338
+ as audit history. Use `loops workflows recover` only for interrupted `running`
339
+ workflow runs whose recorded child process is gone; terminal `timed_out` runs
340
+ must be requeued by re-delivering or draining the original task/event route.
341
+
313
342
  ```json
314
343
  {
315
344
  "id": "custom-report",
@@ -318,8 +347,7 @@ registry.
318
347
  "kind": "workflow",
319
348
  "variables": [
320
349
  { "name": "objective", "required": true, "description": "Report objective." },
321
- { "name": "projectPath", "required": true, "description": "Working directory." },
322
- { "name": "timeoutMs", "default": "300000", "type": "number" }
350
+ { "name": "projectPath", "required": true, "description": "Working directory." }
323
351
  ],
324
352
  "workflow": {
325
353
  "name": "custom-report-${objective}",
@@ -334,9 +362,9 @@ registry.
334
362
  "configIsolation": "safe",
335
363
  "permissionMode": "bypass",
336
364
  "sandbox": "workspace-write",
337
- "timeoutMs": "${timeoutMs}"
365
+ "timeoutMs": null
338
366
  },
339
- "timeoutMs": "${timeoutMs}"
367
+ "timeoutMs": null
340
368
  }
341
369
  ]
342
370
  }
@@ -48,7 +48,7 @@
48
48
  "type": "agent",
49
49
  "provider": "codewith",
50
50
  "cwd": "/path/to/repo",
51
- "timeoutMs": 1800000,
51
+ "timeoutMs": null,
52
52
  "prompt": "Read .openloops/transcripts/latest-transcript.json and create or update docs/loop-backlog.md. Extract only recurring work that is useful enough to schedule through OpenLoops. For each candidate include cadence, target provider, allowed write scope, expected artifacts, verification command, stop condition, and the specific transcript insight that justifies it. Prefer loops for code review/security, customer feedback triage, maintenance PRs, CI optimization, knowledge capture, and workflow hygiene."
53
53
  }
54
54
  },
@@ -60,7 +60,7 @@
60
60
  "type": "agent",
61
61
  "provider": "codewith",
62
62
  "cwd": "/path/to/repo",
63
- "timeoutMs": 1800000,
63
+ "timeoutMs": null,
64
64
  "prompt": "Use docs/loop-backlog.md to author or update workflow JSON specs under docs/workflows/generated. Each workflow must use deterministic command steps for checks, agent steps for judgment or code changes, and workflow or step goals only when the outcome needs planning. Keep prompts scoped to the target repo, name allowed paths, and include validation evidence."
65
65
  }
66
66
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.54",
3
+ "version": "0.3.56",
4
4
  "description": "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",