@hasna/loops 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -726,7 +726,7 @@ function buildAgentInvocation(target) {
726
726
  if (target.model)
727
727
  args.push("--model", target.model);
728
728
  args.push(...target.extraArgs ?? []);
729
- args.push("agent", "start");
729
+ args.push("agent", "start", "--json");
730
730
  if (target.cwd)
731
731
  args.push("--cwd", target.cwd);
732
732
  args.push(target.prompt);
@@ -1493,11 +1493,14 @@ function ensurePrivateStorePath(file) {
1493
1493
  class Store {
1494
1494
  db;
1495
1495
  rootDir;
1496
+ memoryRootDir;
1496
1497
  constructor(path) {
1497
1498
  const file = path ?? dbPath();
1498
1499
  if (file !== ":memory:")
1499
1500
  ensurePrivateStorePath(file);
1500
1501
  this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname2(file);
1502
+ if (file === ":memory:")
1503
+ this.memoryRootDir = this.rootDir;
1501
1504
  this.db = new Database(file);
1502
1505
  this.db.exec("PRAGMA foreign_keys = ON;");
1503
1506
  this.db.exec("PRAGMA busy_timeout = 5000;");
@@ -1950,9 +1953,12 @@ class Store {
1950
1953
  const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
1951
1954
  if (rows.length === 0)
1952
1955
  throw new LoopNotFoundError(idOrName);
1953
- if (rows.length > 1)
1956
+ if (rows.length === 1)
1957
+ return rowToLoop(rows[0]);
1958
+ const active = this.db.query("SELECT * FROM loops WHERE name = ? AND archived_at IS NULL ORDER BY created_at DESC LIMIT 2").all(idOrName);
1959
+ if (active.length !== 1)
1954
1960
  throw new AmbiguousNameError(idOrName);
1955
- return rowToLoop(rows[0]);
1961
+ return rowToLoop(active[0]);
1956
1962
  }
1957
1963
  requireLoop(idOrName) {
1958
1964
  return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
@@ -2293,6 +2299,7 @@ class Store {
2293
2299
  return this.transact(() => {
2294
2300
  const loop = this.requireLoop(idOrName);
2295
2301
  this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
2302
+ this.db.query("DELETE FROM loop_runs WHERE loop_id = ?").run(loop.id);
2296
2303
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
2297
2304
  return res.changes > 0;
2298
2305
  });
@@ -2777,7 +2784,10 @@ class Store {
2777
2784
  return rowToGoal(row);
2778
2785
  }
2779
2786
  if (context.sourceType && context.sourceId) {
2780
- const row = this.db.query("SELECT * FROM goals WHERE source_type = ? AND source_id = ? ORDER BY created_at DESC LIMIT 1").get(context.sourceType, context.sourceId);
2787
+ const terminalPlaceholders = GOAL_TERMINAL.map(() => "?").join(", ");
2788
+ const row = this.db.query(`SELECT * FROM goals
2789
+ WHERE source_type = ? AND source_id = ? AND status NOT IN (${terminalPlaceholders})
2790
+ ORDER BY created_at DESC LIMIT 1`).get(context.sourceType, context.sourceId, ...GOAL_TERMINAL);
2781
2791
  if (row)
2782
2792
  return rowToGoal(row);
2783
2793
  }
@@ -3197,6 +3207,41 @@ class Store {
3197
3207
  throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
3198
3208
  return run;
3199
3209
  }
3210
+ recordWorkflowStepProgress(workflowRunId, stepId, progress, opts = {}) {
3211
+ const now = (opts.now ?? new Date).toISOString();
3212
+ this.db.exec("BEGIN IMMEDIATE");
3213
+ try {
3214
+ const res = this.db.query(`UPDATE workflow_step_runs
3215
+ SET stdout=COALESCE($stdout, stdout),
3216
+ stderr=COALESCE($stderr, stderr),
3217
+ updated_at=$updated
3218
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
3219
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3220
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3221
+ ))`).run({
3222
+ $workflowRunId: workflowRunId,
3223
+ $stepId: stepId,
3224
+ $stdout: progress.stdout === undefined ? null : scrubbedOrNull(progress.stdout),
3225
+ $stderr: progress.stderr === undefined ? null : scrubbedOrNull(progress.stderr),
3226
+ $updated: now,
3227
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
3228
+ $now: now
3229
+ });
3230
+ if (res.changes === 1) {
3231
+ this.appendWorkflowEvent(workflowRunId, "step_progress", stepId, progress.payload);
3232
+ }
3233
+ this.db.exec("COMMIT");
3234
+ } catch (error) {
3235
+ try {
3236
+ this.db.exec("ROLLBACK");
3237
+ } catch {}
3238
+ throw error;
3239
+ }
3240
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
3241
+ if (!run)
3242
+ throw new Error(`workflow step run not found after progress update: ${workflowRunId}/${stepId}`);
3243
+ return run;
3244
+ }
3200
3245
  recoverWorkflowRun(workflowRunId, reason = "workflow run recovered for retry") {
3201
3246
  return this.transact(() => {
3202
3247
  const now = nowIso();
@@ -3634,7 +3679,8 @@ class Store {
3634
3679
  AND ($daemonLeaseId IS NULL OR EXISTS (
3635
3680
  SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3636
3681
  ))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, claim_token=NULL, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
3637
- duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
3682
+ duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
3683
+ WHERE id=$id AND status='running'`).run(params);
3638
3684
  const run = this.getRun(id);
3639
3685
  if (!run)
3640
3686
  throw new Error(`run not found after finalize: ${id}`);
@@ -4162,6 +4208,12 @@ class Store {
4162
4208
  }
4163
4209
  close() {
4164
4210
  this.db.close();
4211
+ if (this.memoryRootDir) {
4212
+ try {
4213
+ rmSync2(this.memoryRootDir, { recursive: true, force: true });
4214
+ } catch {}
4215
+ this.memoryRootDir = undefined;
4216
+ }
4165
4217
  }
4166
4218
  }
4167
4219
 
@@ -4599,7 +4651,7 @@ ${failure.evidence.stderr}` : undefined
4599
4651
 
4600
4652
  `);
4601
4653
  const dedupeKey = `openloops:${loop.id}:${failure.fingerprint}`;
4602
- const tags = ["bug", "openloops", "loop-health", failure.classification];
4654
+ const tags = ["bug", "openloops", "loops", "loop-health", failure.classification];
4603
4655
  const priority = failure.classification === "auth" || failure.classification === "rate_limit" ? "high" : "medium";
4604
4656
  return {
4605
4657
  title,
@@ -4614,7 +4666,7 @@ ${failure.evidence.stderr}` : undefined
4614
4666
  comment: ["todos", "comment", "<task-id>", description]
4615
4667
  },
4616
4668
  futureNativeUpsert: {
4617
- command: "todos upsert",
4669
+ command: "todos task upsert",
4618
4670
  fields: {
4619
4671
  title,
4620
4672
  description,
@@ -5043,7 +5095,12 @@ function notifySpawn(pid, opts) {
5043
5095
  if (!pid)
5044
5096
  return;
5045
5097
  opts.onSpawn?.(pid);
5046
- opts.onSpawnProcess?.({ pid, pgid: pid, processStartedAt: nowIso() });
5098
+ const startedMs = processStartTimeMs(pid);
5099
+ opts.onSpawnProcess?.({
5100
+ pid,
5101
+ pgid: pid,
5102
+ processStartedAt: startedMs !== undefined ? new Date(startedMs).toISOString() : nowIso()
5103
+ });
5047
5104
  }
5048
5105
  function shellQuote(value) {
5049
5106
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -5368,18 +5425,60 @@ function codewithAgentControlArgs(target, command, agentId) {
5368
5425
  "agent",
5369
5426
  command,
5370
5427
  ...command === "logs" ? ["--limit", "20"] : [],
5428
+ "--json",
5371
5429
  agentId
5372
5430
  ];
5373
5431
  }
5432
+ function extractFirstJsonObject(text) {
5433
+ let depth = 0;
5434
+ let start = -1;
5435
+ let inString = false;
5436
+ let escaped = false;
5437
+ for (let i = 0;i < text.length; i += 1) {
5438
+ const ch = text[i];
5439
+ if (inString) {
5440
+ if (escaped)
5441
+ escaped = false;
5442
+ else if (ch === "\\")
5443
+ escaped = true;
5444
+ else if (ch === '"')
5445
+ inString = false;
5446
+ continue;
5447
+ }
5448
+ if (ch === '"') {
5449
+ inString = true;
5450
+ } else if (ch === "{") {
5451
+ if (depth === 0)
5452
+ start = i;
5453
+ depth += 1;
5454
+ } else if (ch === "}") {
5455
+ if (depth > 0) {
5456
+ depth -= 1;
5457
+ if (depth === 0 && start !== -1)
5458
+ return text.slice(start, i + 1);
5459
+ }
5460
+ }
5461
+ }
5462
+ return;
5463
+ }
5374
5464
  function parseJsonOutput(stdout, label) {
5375
- try {
5376
- const value = JSON.parse(stdout || "{}");
5377
- if (!value || typeof value !== "object" || Array.isArray(value))
5378
- throw new Error("not an object");
5379
- return value;
5380
- } catch (error) {
5381
- throw new Error(`${label} did not return JSON${error instanceof Error ? `: ${error.message}` : ""}`);
5465
+ const raw = stdout || "{}";
5466
+ const candidates = [raw.trim()];
5467
+ const scanned = extractFirstJsonObject(raw);
5468
+ if (scanned && scanned !== raw.trim())
5469
+ candidates.push(scanned);
5470
+ let lastError;
5471
+ for (const candidate of candidates) {
5472
+ try {
5473
+ const value = JSON.parse(candidate);
5474
+ if (!value || typeof value !== "object" || Array.isArray(value))
5475
+ throw new Error("not an object");
5476
+ return value;
5477
+ } catch (error) {
5478
+ lastError = error;
5479
+ }
5382
5480
  }
5481
+ throw new Error(`${label} did not return JSON${lastError instanceof Error ? `: ${lastError.message}` : ""}`);
5383
5482
  }
5384
5483
  function recordField(value, key) {
5385
5484
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -5398,6 +5497,13 @@ function numberField(value, key) {
5398
5497
  function codewithAgentStatus(readJson) {
5399
5498
  return stringField(recordField(readJson, "agent"), "status") ?? stringField(recordField(readJson, "statusSnapshot"), "status");
5400
5499
  }
5500
+ function codewithAgentLastEventSeq(readJson) {
5501
+ const agentSeq = numberField(recordField(readJson, "agent"), "lastEventSeq");
5502
+ const snapshotSeq = numberField(recordField(readJson, "statusSnapshot"), "lastEventSeq");
5503
+ if (agentSeq !== undefined && snapshotSeq !== undefined)
5504
+ return Math.max(agentSeq, snapshotSeq);
5505
+ return agentSeq ?? snapshotSeq;
5506
+ }
5401
5507
  function codewithAgentEvidence(startJson, readJson, logsJson) {
5402
5508
  const agent = recordField(readJson, "agent") ?? recordField(startJson, "agent");
5403
5509
  const statusSnapshot = recordField(readJson, "statusSnapshot");
@@ -5428,6 +5534,21 @@ function codewithAgentEvidence(startJson, readJson, logsJson) {
5428
5534
  events
5429
5535
  }, null, 2);
5430
5536
  }
5537
+ function codewithAgentProgress(readJson) {
5538
+ const agent = recordField(readJson, "agent");
5539
+ const statusSnapshot = recordField(readJson, "statusSnapshot");
5540
+ return {
5541
+ provider: "codewith",
5542
+ agentId: stringField(agent, "agentId"),
5543
+ status: stringField(agent, "status") ?? stringField(statusSnapshot, "status"),
5544
+ summary: stringField(statusSnapshot, "summary"),
5545
+ statusReason: stringField(agent, "statusReason"),
5546
+ threadId: stringField(agent, "threadId"),
5547
+ rolloutPath: stringField(agent, "rolloutPath"),
5548
+ pid: numberField(agent, "pid"),
5549
+ lastEventSeq: codewithAgentLastEventSeq(readJson)
5550
+ };
5551
+ }
5431
5552
  function sleep(ms) {
5432
5553
  return new Promise((resolve3) => setTimeout(resolve3, ms));
5433
5554
  }
@@ -5462,6 +5583,7 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
5462
5583
  if (!agentId) {
5463
5584
  return failureResult(startedAt, "codewith agent start did not return agent.agentId", { stdout: start.stdout, stderr: stderr.value() });
5464
5585
  }
5586
+ opts.onAgentProgress?.(codewithAgentProgress(startJson));
5465
5587
  const stopAgent = async () => {
5466
5588
  await spawnCapture(spec.command, codewithAgentControlArgs(target, "stop", agentId), {
5467
5589
  cwd: spec.cwd,
@@ -5504,10 +5626,15 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
5504
5626
  });
5505
5627
  }
5506
5628
  const status = codewithAgentStatus(lastReadJson);
5507
- const fingerprint = JSON.stringify({ status, snapshot: recordField(lastReadJson, "statusSnapshot") ?? null });
5629
+ const fingerprint = JSON.stringify({
5630
+ status,
5631
+ agentLastEventSeq: numberField(recordField(lastReadJson, "agent"), "lastEventSeq"),
5632
+ snapshot: recordField(lastReadJson, "statusSnapshot") ?? null
5633
+ });
5508
5634
  if (fingerprint !== lastFingerprint) {
5509
5635
  lastFingerprint = fingerprint;
5510
5636
  lastProgressAt = Date.now();
5637
+ opts.onAgentProgress?.(codewithAgentProgress(lastReadJson));
5511
5638
  }
5512
5639
  if (status === "completed" || status === "failed" || status === "cancelled") {
5513
5640
  const logs = await spawnCapture(spec.command, codewithAgentControlArgs(target, "logs", agentId), {
@@ -6508,173 +6635,213 @@ async function executeWorkflow(store, workflow, opts = {}) {
6508
6635
  if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
6509
6636
  return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), store.listWorkflowStepRuns(run.id), run.error);
6510
6637
  }
6638
+ const resumedRunningSteps = store.listWorkflowStepRuns(run.id).filter((step) => step.status === "running");
6639
+ if (resumedRunningSteps.length > 0 && resumedRunningSteps.every((step) => step.pid !== undefined)) {
6640
+ try {
6641
+ store.recoverWorkflowRun(run.id, "workflow run resumed after lease takeover");
6642
+ } catch {}
6643
+ }
6511
6644
  const ordered = workflowExecutionOrder(workflow);
6512
6645
  const byId = new Map(workflow.steps.map((step) => [step.id, step]));
6513
6646
  let blockingError;
6514
6647
  let terminalStatus = "succeeded";
6515
- for (const step of ordered) {
6516
- if (store.isWorkflowRunTerminal(run.id)) {
6517
- terminalStatus = store.requireWorkflowRun(run.id).status;
6518
- blockingError = "workflow run was cancelled";
6519
- break;
6520
- }
6521
- const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
6522
- if (pendingTimeout) {
6523
- terminalStatus = "timed_out";
6524
- blockingError = pendingTimeout;
6525
- break;
6526
- }
6527
- const existing = store.getWorkflowStepRun(run.id, step.id);
6528
- if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
6529
- continue;
6530
- const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
6531
- const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
6532
- const dependencyStep = byId.get(dependencyId);
6533
- if (dependencyRun?.status === "succeeded")
6534
- return false;
6535
- return !dependencyStep?.continueOnFailure;
6536
- });
6537
- if (blockedBy) {
6538
- opts.beforePersist?.();
6539
- if (isBlockedStepRun(store.getWorkflowStepRun(run.id, blockedBy))) {
6540
- store.skipWorkflowStepRun(run.id, step.id, `${BLOCKED_STEP_ERROR_PREFIX} upstream step ${blockedBy} was blocked`, {
6541
- daemonLeaseId: opts.daemonLeaseId
6542
- });
6648
+ try {
6649
+ for (const step of ordered) {
6650
+ if (store.isWorkflowRunTerminal(run.id)) {
6651
+ terminalStatus = store.requireWorkflowRun(run.id).status;
6652
+ blockingError = "workflow run was cancelled";
6653
+ break;
6654
+ }
6655
+ const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
6656
+ if (pendingTimeout) {
6657
+ terminalStatus = "timed_out";
6658
+ blockingError = pendingTimeout;
6659
+ break;
6660
+ }
6661
+ const existing = store.getWorkflowStepRun(run.id, step.id);
6662
+ if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
6663
+ continue;
6664
+ const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
6665
+ const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
6666
+ const dependencyStep = byId.get(dependencyId);
6667
+ if (dependencyRun?.status === "succeeded")
6668
+ return false;
6669
+ return !dependencyStep?.continueOnFailure;
6670
+ });
6671
+ if (blockedBy) {
6672
+ opts.beforePersist?.();
6673
+ if (isBlockedStepRun(store.getWorkflowStepRun(run.id, blockedBy))) {
6674
+ store.skipWorkflowStepRun(run.id, step.id, `${BLOCKED_STEP_ERROR_PREFIX} upstream step ${blockedBy} was blocked`, {
6675
+ daemonLeaseId: opts.daemonLeaseId
6676
+ });
6677
+ continue;
6678
+ }
6679
+ store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
6680
+ blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
6681
+ terminalStatus = "failed";
6543
6682
  continue;
6544
6683
  }
6545
- store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
6546
- blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
6547
- terminalStatus = "failed";
6548
- continue;
6549
- }
6550
- opts.beforePersist?.();
6551
- const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
6552
- if (startedStep.status !== "running") {
6553
- terminalStatus = "failed";
6554
- blockingError = `step ${step.id} could not start because workflow is no longer running`;
6555
- break;
6556
- }
6557
- const stepContext = goalExecutionContext({
6558
- loop: opts.loop,
6559
- loopRun: opts.loopRun,
6560
- scheduledFor: opts.scheduledFor,
6561
- workflow,
6562
- workflowRunId: run.id,
6563
- workflowStepId: step.id
6564
- });
6565
- let result;
6566
- const controller = new AbortController;
6567
- const externalAbort = () => controller.abort();
6568
- if (opts.signal?.aborted)
6569
- controller.abort();
6570
- opts.signal?.addEventListener("abort", externalAbort, { once: true });
6571
- const cancelTimer = setInterval(() => {
6572
- if (store.getWorkflowRun(run.id)?.status === "cancelled")
6684
+ opts.beforePersist?.();
6685
+ const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
6686
+ if (startedStep.status !== "running") {
6687
+ terminalStatus = "failed";
6688
+ blockingError = `step ${step.id} could not start because workflow is no longer running`;
6689
+ break;
6690
+ }
6691
+ const stepContext = goalExecutionContext({
6692
+ loop: opts.loop,
6693
+ loopRun: opts.loopRun,
6694
+ scheduledFor: opts.scheduledFor,
6695
+ workflow,
6696
+ workflowRunId: run.id,
6697
+ workflowStepId: step.id
6698
+ });
6699
+ let result;
6700
+ const controller = new AbortController;
6701
+ const externalAbort = () => controller.abort();
6702
+ if (opts.signal?.aborted)
6573
6703
  controller.abort();
6574
- }, opts.cancelPollMs ?? 500);
6575
- cancelTimer.unref();
6576
- try {
6577
- if (step.goal) {
6578
- result = await runGoal(store, step.goal, {
6579
- ...opts,
6580
- model: opts.goalModel,
6581
- target: targetWithStepAccount(step),
6582
- signal: controller.signal,
6583
- context: stepContext
6584
- });
6585
- } else {
6586
- result = await executeTarget(targetWithStepAccount(step, opts.goalNodePrompt), executionMetadata(stepContext), {
6587
- ...opts,
6588
- machine: opts.machine ?? opts.loop?.machine,
6589
- signal: controller.signal,
6590
- onSpawn: (pid) => {
6591
- opts.beforePersist?.();
6592
- store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
6593
- opts.onSpawn?.(pid);
6594
- }
6704
+ opts.signal?.addEventListener("abort", externalAbort, { once: true });
6705
+ const cancelTimer = setInterval(() => {
6706
+ if (store.getWorkflowRun(run.id)?.status === "cancelled")
6707
+ controller.abort();
6708
+ }, opts.cancelPollMs ?? 500);
6709
+ cancelTimer.unref();
6710
+ try {
6711
+ if (step.goal) {
6712
+ result = await runGoal(store, step.goal, {
6713
+ ...opts,
6714
+ model: opts.goalModel,
6715
+ target: targetWithStepAccount(step),
6716
+ signal: controller.signal,
6717
+ context: stepContext
6718
+ });
6719
+ } else {
6720
+ result = await executeTarget(targetWithStepAccount(step, opts.goalNodePrompt), executionMetadata(stepContext), {
6721
+ ...opts,
6722
+ machine: opts.machine ?? opts.loop?.machine,
6723
+ signal: controller.signal,
6724
+ onAgentProgress: (progress) => {
6725
+ const stdout = JSON.stringify({ agentProgress: progress }, null, 2);
6726
+ opts.beforePersist?.();
6727
+ store.recordWorkflowStepProgress(run.id, step.id, {
6728
+ stdout,
6729
+ payload: progress
6730
+ }, {
6731
+ daemonLeaseId: opts.daemonLeaseId
6732
+ });
6733
+ opts.onAgentProgress?.(progress);
6734
+ },
6735
+ onSpawn: (pid) => {
6736
+ opts.beforePersist?.();
6737
+ store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
6738
+ opts.onSpawn?.(pid);
6739
+ }
6740
+ });
6741
+ }
6742
+ } catch (error) {
6743
+ const finishedAt2 = nowIso();
6744
+ result = {
6745
+ status: "failed",
6746
+ stdout: "",
6747
+ stderr: "",
6748
+ error: error instanceof Error ? error.message : String(error),
6749
+ startedAt: startedStep.startedAt ?? finishedAt2,
6750
+ finishedAt: finishedAt2,
6751
+ durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
6752
+ };
6753
+ } finally {
6754
+ clearInterval(cancelTimer);
6755
+ opts.signal?.removeEventListener("abort", externalAbort);
6756
+ }
6757
+ const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
6758
+ if (timeoutMessage && result.status === "failed") {
6759
+ result = { ...result, status: "timed_out", error: timeoutMessage };
6760
+ }
6761
+ if (store.isWorkflowRunTerminal(run.id)) {
6762
+ terminalStatus = store.requireWorkflowRun(run.id).status;
6763
+ blockingError = "workflow run was cancelled";
6764
+ break;
6765
+ }
6766
+ opts.beforePersist?.();
6767
+ const blockedExit = result.status === "failed" && result.exitCode !== undefined && blockedExitCodesForStep(step).includes(result.exitCode);
6768
+ if (blockedExit) {
6769
+ store.finalizeWorkflowStepRun(run.id, step.id, {
6770
+ status: "skipped",
6771
+ finishedAt: result.finishedAt,
6772
+ durationMs: result.durationMs,
6773
+ stdout: result.stdout,
6774
+ stderr: result.stderr,
6775
+ exitCode: result.exitCode,
6776
+ error: `${BLOCKED_STEP_ERROR_PREFIX} step ${step.id} exited with blocked exit code ${result.exitCode}`
6777
+ }, {
6778
+ daemonLeaseId: opts.daemonLeaseId
6595
6779
  });
6780
+ continue;
6596
6781
  }
6597
- } catch (error) {
6598
- const finishedAt2 = nowIso();
6599
- result = {
6600
- status: "failed",
6601
- stdout: "",
6602
- stderr: "",
6603
- error: error instanceof Error ? error.message : String(error),
6604
- startedAt: startedStep.startedAt ?? finishedAt2,
6605
- finishedAt: finishedAt2,
6606
- durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
6607
- };
6608
- } finally {
6609
- clearInterval(cancelTimer);
6610
- opts.signal?.removeEventListener("abort", externalAbort);
6611
- }
6612
- const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
6613
- if (timeoutMessage && result.status === "failed") {
6614
- result = { ...result, status: "timed_out", error: timeoutMessage };
6615
- }
6616
- if (store.isWorkflowRunTerminal(run.id)) {
6617
- terminalStatus = store.requireWorkflowRun(run.id).status;
6618
- blockingError = "workflow run was cancelled";
6619
- break;
6620
- }
6621
- opts.beforePersist?.();
6622
- const blockedExit = result.status === "failed" && result.exitCode !== undefined && blockedExitCodesForStep(step).includes(result.exitCode);
6623
- if (blockedExit) {
6624
6782
  store.finalizeWorkflowStepRun(run.id, step.id, {
6625
- status: "skipped",
6783
+ status: result.status,
6626
6784
  finishedAt: result.finishedAt,
6627
6785
  durationMs: result.durationMs,
6628
6786
  stdout: result.stdout,
6629
6787
  stderr: result.stderr,
6630
6788
  exitCode: result.exitCode,
6631
- error: `${BLOCKED_STEP_ERROR_PREFIX} step ${step.id} exited with blocked exit code ${result.exitCode}`
6789
+ error: result.error
6632
6790
  }, {
6633
6791
  daemonLeaseId: opts.daemonLeaseId
6634
6792
  });
6635
- continue;
6793
+ if (result.status !== "succeeded" && !step.continueOnFailure) {
6794
+ terminalStatus = result.status;
6795
+ blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
6796
+ break;
6797
+ }
6636
6798
  }
6637
- store.finalizeWorkflowStepRun(run.id, step.id, {
6638
- status: result.status,
6639
- finishedAt: result.finishedAt,
6640
- durationMs: result.durationMs,
6641
- stdout: result.stdout,
6642
- stderr: result.stderr,
6643
- exitCode: result.exitCode,
6644
- error: result.error
6799
+ if (terminalStatus !== "succeeded") {
6800
+ for (const step of ordered) {
6801
+ const existing = store.getWorkflowStepRun(run.id, step.id);
6802
+ if (existing?.status === "pending" || existing?.status === "running") {
6803
+ store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
6804
+ daemonLeaseId: opts.daemonLeaseId
6805
+ });
6806
+ }
6807
+ }
6808
+ }
6809
+ const finishedAt = nowIso();
6810
+ if (store.isWorkflowRunTerminal(run.id)) {
6811
+ const terminalRun = store.requireWorkflowRun(run.id);
6812
+ return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, store.listWorkflowStepRuns(run.id), terminalRun.error ?? blockingError);
6813
+ }
6814
+ opts.beforePersist?.();
6815
+ const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
6816
+ finishedAt,
6817
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
6818
+ error: blockingError
6645
6819
  }, {
6646
6820
  daemonLeaseId: opts.daemonLeaseId
6647
6821
  });
6648
- if (result.status !== "succeeded" && !step.continueOnFailure) {
6649
- terminalStatus = result.status;
6650
- blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
6651
- break;
6652
- }
6653
- }
6654
- if (terminalStatus !== "succeeded") {
6655
- for (const step of ordered) {
6656
- const existing = store.getWorkflowStepRun(run.id, step.id);
6657
- if (existing?.status === "pending" || existing?.status === "running") {
6658
- store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
6822
+ return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), blockingError);
6823
+ } catch (error) {
6824
+ if (opts.daemonLeaseId && error instanceof Error && error.message === "daemon lease lost")
6825
+ throw error;
6826
+ if (error instanceof Error && error.message.includes("workflow step is not claimable"))
6827
+ throw error;
6828
+ const message = error instanceof Error ? error.message : String(error);
6829
+ const finishedAt = nowIso();
6830
+ try {
6831
+ if (!store.isWorkflowRunTerminal(run.id)) {
6832
+ store.finalizeWorkflowRun(run.id, "failed", {
6833
+ finishedAt,
6834
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
6835
+ error: message
6836
+ }, {
6659
6837
  daemonLeaseId: opts.daemonLeaseId
6660
6838
  });
6661
6839
  }
6662
- }
6663
- }
6664
- const finishedAt = nowIso();
6665
- if (store.isWorkflowRunTerminal(run.id)) {
6666
- const terminalRun = store.requireWorkflowRun(run.id);
6667
- return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, store.listWorkflowStepRuns(run.id), terminalRun.error ?? blockingError);
6840
+ } catch {}
6841
+ const current = store.getWorkflowRun(run.id) ?? run;
6842
+ const resultStatus = current.status === "running" ? "failed" : current.status;
6843
+ return workflowResult(current, resultStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), current.error ?? message);
6668
6844
  }
6669
- opts.beforePersist?.();
6670
- const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
6671
- finishedAt,
6672
- durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
6673
- error: blockingError
6674
- }, {
6675
- daemonLeaseId: opts.daemonLeaseId
6676
- });
6677
- return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), blockingError);
6678
6845
  }
6679
6846
  function preflightWorkflow(workflow, opts = {}) {
6680
6847
  return workflowExecutionOrder(workflow).map((step) => {
@@ -7045,6 +7212,25 @@ function advanceOptions(deps) {
7045
7212
  onRun: deps.onRun
7046
7213
  };
7047
7214
  }
7215
+ var TERMINAL_RUN_STATUSES2 = new Set([
7216
+ "succeeded",
7217
+ "failed",
7218
+ "timed_out",
7219
+ "abandoned",
7220
+ "skipped"
7221
+ ]);
7222
+ function repairWedgedTerminalSlot(deps, loop, scheduledFor, now) {
7223
+ const existing = deps.store.getRunBySlot(loop.id, scheduledFor);
7224
+ if (!existing || !TERMINAL_RUN_STATUSES2.has(existing.status))
7225
+ return;
7226
+ try {
7227
+ advanceLoop(deps.store, loop, existing, new Date(existing.finishedAt ?? now), existing.status === "succeeded", advanceOptions(deps));
7228
+ } catch (error) {
7229
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
7230
+ return;
7231
+ throw error;
7232
+ }
7233
+ }
7048
7234
  async function runSlot(deps, loop, scheduledFor) {
7049
7235
  const now = deps.now?.() ?? new Date;
7050
7236
  deps.beforeRun?.(loop, scheduledFor);
@@ -7071,8 +7257,10 @@ async function runSlot(deps, loop, scheduledFor) {
7071
7257
  return;
7072
7258
  throw error;
7073
7259
  }
7074
- if (!claim)
7260
+ if (!claim) {
7261
+ repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
7075
7262
  return;
7263
+ }
7076
7264
  deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
7077
7265
  deps.onRun?.(claim.run);
7078
7266
  const finalRun = await executeClaimedRun({
@@ -7118,8 +7306,10 @@ function claimSlot(deps, loop, scheduledFor) {
7118
7306
  return;
7119
7307
  throw error;
7120
7308
  }
7121
- if (!claim)
7309
+ if (!claim) {
7310
+ repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
7122
7311
  return;
7312
+ }
7123
7313
  deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
7124
7314
  deps.onRun?.(claim.run);
7125
7315
  return claim;
@@ -7400,10 +7590,7 @@ async function stopDaemon(opts = {}) {
7400
7590
  const store = new Store(join5(dirname4(path), "loops.db"));
7401
7591
  try {
7402
7592
  const lease = store.getDaemonLease();
7403
- if (!lease || lease.pid !== state.pid || new Date(lease.expiresAt).getTime() <= Date.now()) {
7404
- removePid(path);
7405
- return { wasRunning: false, stopped: false, forced: false, pid: state.pid };
7406
- }
7593
+ const leaseVerified = Boolean(lease && lease.pid === state.pid && new Date(lease.expiresAt).getTime() > Date.now());
7407
7594
  try {
7408
7595
  process.kill(state.pid, "SIGTERM");
7409
7596
  } catch {
@@ -7423,11 +7610,14 @@ async function stopDaemon(opts = {}) {
7423
7610
  } catch {}
7424
7611
  await sleep2(150);
7425
7612
  removePid(path);
7426
- const ownedRuns = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.claimedBy !== undefined && run.claimedBy.endsWith(`:${lease.id}`));
7427
- const reapedPgids = await reapProcessGroups(ownedRuns.map(toReapableProcess), {
7428
- sleep: sleep2,
7429
- graceMs: opts.reapGraceMs
7430
- });
7613
+ let reapedPgids = [];
7614
+ if (leaseVerified && lease) {
7615
+ const ownedRuns = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.claimedBy !== undefined && run.claimedBy.endsWith(`:${lease.id}`));
7616
+ reapedPgids = await reapProcessGroups(ownedRuns.map(toReapableProcess), {
7617
+ sleep: sleep2,
7618
+ graceMs: opts.reapGraceMs
7619
+ });
7620
+ }
7431
7621
  return { wasRunning: true, stopped: !isAlive(state.pid, record?.startedAt), forced: true, pid: state.pid, reapedPgids };
7432
7622
  } finally {
7433
7623
  store.close();
@@ -7612,7 +7802,14 @@ async function runDaemon(opts = {}) {
7612
7802
  try {
7613
7803
  const liveInlineOwner = (run) => {
7614
7804
  const ownerPid = inlineRunnerOwnerPid(run.claimedBy);
7615
- return ownerPid !== undefined && isAlive(ownerPid);
7805
+ if (ownerPid === undefined || !isAlive(ownerPid))
7806
+ return false;
7807
+ const ownerStartMs = processStartTimeMs(ownerPid);
7808
+ const runStartMs = run.startedAt ? Date.parse(run.startedAt) : Number.NaN;
7809
+ if (ownerStartMs !== undefined && Number.isFinite(runStartMs) && ownerStartMs > runStartMs + START_TIME_TOLERANCE_MS) {
7810
+ return false;
7811
+ }
7812
+ return true;
7616
7813
  };
7617
7814
  const staleCandidates = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.leaseExpiresAt !== undefined && new Date(run.leaseExpiresAt).getTime() <= Date.now());
7618
7815
  const startup = claimDueRuns({ store, runnerId, daemonLeaseId: leaseId, maxClaims: 0 });
@@ -7806,7 +8003,7 @@ function enableStartup(result) {
7806
8003
  // package.json
7807
8004
  var package_default = {
7808
8005
  name: "@hasna/loops",
7809
- version: "0.4.5",
8006
+ version: "0.4.7",
7810
8007
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7811
8008
  type: "module",
7812
8009
  main: "dist/index.js",