@hasna/loops 0.3.56 → 0.3.57

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/sdk/index.js CHANGED
@@ -459,6 +459,9 @@ function validateTarget(value, label, opts) {
459
459
  }
460
460
  if (value.model !== undefined)
461
461
  assertString(value.model, `${label}.model`);
462
+ if (value.provider === "opencode" && (typeof value.model !== "string" || value.model.trim() === "")) {
463
+ throw new Error(`${label}.model is required for provider opencode; pass a provider/model id such as openrouter/google/gemini-2.5-flash`);
464
+ }
462
465
  if (value.variant !== undefined)
463
466
  assertString(value.variant, `${label}.variant`);
464
467
  if (value.agent !== undefined)
@@ -1761,7 +1764,7 @@ class Store {
1761
1764
  if (!sourceDedupeKey)
1762
1765
  throw new Error("cannot refresh workflow invocation without sourceRef.dedupeKey");
1763
1766
  const now = nowIso();
1764
- const claimableStatuses = ["queued", "deferred", "failed", "dead_letter", "cancelled"];
1767
+ const claimableStatuses = ["queued", "deferred"];
1765
1768
  const statusBindings = Object.fromEntries(claimableStatuses.map((status, index) => [`$status${index}`, status]));
1766
1769
  const placeholders = claimableStatuses.map((_, index) => `$status${index}`).join(",");
1767
1770
  const result = this.db.query(`UPDATE workflow_invocations
@@ -1842,28 +1845,35 @@ class Store {
1842
1845
  project_group=excluded.project_group,
1843
1846
  priority=excluded.priority,
1844
1847
  status=CASE
1845
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
1848
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
1846
1849
  THEN workflow_work_items.status
1847
1850
  ELSE excluded.status
1848
1851
  END,
1849
1852
  workflow_id=CASE
1850
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
1853
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_id
1851
1854
  ELSE NULL
1852
1855
  END,
1853
1856
  loop_id=CASE
1854
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
1857
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.loop_id
1855
1858
  ELSE NULL
1856
1859
  END,
1857
1860
  workflow_run_id=CASE
1858
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
1861
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_run_id
1859
1862
  ELSE NULL
1860
1863
  END,
1861
1864
  lease_expires_at=CASE
1862
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
1865
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.lease_expires_at
1863
1866
  ELSE NULL
1864
1867
  END,
1865
1868
  next_attempt_at=excluded.next_attempt_at,
1866
- last_reason=COALESCE(excluded.last_reason, workflow_work_items.last_reason),
1869
+ last_reason=CASE
1870
+ WHEN workflow_work_items.attempts > 0
1871
+ AND workflow_work_items.status IN ('queued', 'deferred')
1872
+ AND workflow_work_items.last_reason IS NOT NULL
1873
+ AND excluded.last_reason IS NOT NULL
1874
+ THEN workflow_work_items.last_reason || '; ' || excluded.last_reason
1875
+ ELSE COALESCE(excluded.last_reason, workflow_work_items.last_reason)
1876
+ END,
1867
1877
  updated_at=excluded.updated_at`).run({
1868
1878
  $id: id,
1869
1879
  $routeKey: input.routeKey,
@@ -1916,11 +1926,39 @@ class Store {
1916
1926
  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;
1917
1927
  return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
1918
1928
  }
1929
+ requeueWorkflowWorkItem(id, patch = {}) {
1930
+ const current = this.getWorkflowWorkItem(id);
1931
+ if (!current)
1932
+ throw new Error(`workflow work item not found: ${id}`);
1933
+ const requeueableStatuses = ["succeeded", "failed", "dead_letter", "cancelled"];
1934
+ if (!requeueableStatuses.includes(current.status)) {
1935
+ throw new Error(`workflow work item is not requeueable: ${id} status=${current.status}`);
1936
+ }
1937
+ const now = nowIso();
1938
+ const reason = patch.reason?.trim() || `requeued from ${current.status}`;
1939
+ const placeholders = requeueableStatuses.map(() => "?").join(",");
1940
+ const res = this.db.query(`UPDATE workflow_work_items
1941
+ SET status='queued', workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
1942
+ next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
1943
+ WHERE id=? AND status IN (${placeholders})`).run(reason, now, id, ...requeueableStatuses);
1944
+ const item = this.getWorkflowWorkItem(id);
1945
+ if (!item)
1946
+ throw new Error(`workflow work item not found after requeue: ${id}`);
1947
+ if (res.changes !== 1)
1948
+ throw new Error(`workflow work item was not requeued: ${id} status=${item.status}`);
1949
+ return item;
1950
+ }
1919
1951
  admitWorkflowWorkItem(id, patch) {
1920
1952
  const now = nowIso();
1921
1953
  const res = this.db.query(`UPDATE workflow_work_items
1922
1954
  SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
1923
- next_attempt_at=NULL, lease_expires_at=NULL, last_reason=$reason, updated_at=$updated
1955
+ next_attempt_at=NULL,
1956
+ lease_expires_at=NULL,
1957
+ last_reason=CASE
1958
+ WHEN last_reason IS NOT NULL AND $reason IS NOT NULL THEN last_reason || '; ' || $reason
1959
+ ELSE COALESCE($reason, last_reason)
1960
+ END,
1961
+ updated_at=$updated
1924
1962
  WHERE id=$id AND status IN ('queued', 'deferred')`).run({
1925
1963
  $id: id,
1926
1964
  $workflowId: patch.workflowId,
@@ -3454,6 +3492,9 @@ function assertSupportedAgentOptions(target) {
3454
3492
  assertStringOption(target.agent, `${target.provider}.agent`);
3455
3493
  assertStringOption(target.authProfile, `${target.provider}.authProfile`);
3456
3494
  assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
3495
+ if (target.provider === "opencode" && (target.model === undefined || target.model.trim() === "")) {
3496
+ throw new Error("opencode.model is required; pass a provider/model id such as openrouter/google/gemini-2.5-flash");
3497
+ }
3457
3498
  if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
3458
3499
  throw new Error(`${target.provider}.configIsolation must be safe or none`);
3459
3500
  }
@@ -4226,6 +4267,78 @@ function sameBlockerKey(values) {
4226
4267
  return values.map((value) => value.trim()).filter(Boolean).join(`
4227
4268
  `) || "goal completion remains unproven";
4228
4269
  }
4270
+ function planStatusForGoal(goal) {
4271
+ if (goal.status === "usageLimited")
4272
+ return "blocked";
4273
+ return goal.status;
4274
+ }
4275
+ function syncReadyFlags(store, goal, nodes, opts) {
4276
+ const withReady = updateReadyFlags(nodes, planStatusForGoal(goal));
4277
+ for (const node of withReady) {
4278
+ const current = nodes.find((entry) => entry.key === node.key);
4279
+ if (current && current.ready !== node.ready) {
4280
+ store.updateGoalPlanNode(goal.goalId, node.key, { ready: node.ready }, { daemonLeaseId: opts.daemonLeaseId });
4281
+ }
4282
+ }
4283
+ return withReady;
4284
+ }
4285
+ function summarizeLabels(values, limit = 5) {
4286
+ if (values.length <= limit)
4287
+ return values.join(", ");
4288
+ return `${values.slice(0, limit).join(", ")} and ${values.length - limit} more`;
4289
+ }
4290
+ function noReadyDiagnostic(goal, nodes) {
4291
+ const planStatus = planStatusForGoal(goal);
4292
+ const byKey = new Map(nodes.map((node) => [node.key, node]));
4293
+ const pendingNodes = nodes.filter((node) => node.status === "pending");
4294
+ const incompleteNodes = nodes.filter((node) => node.status !== "complete");
4295
+ const pendingDetails = pendingNodes.map((node) => ({
4296
+ key: node.key,
4297
+ status: node.status,
4298
+ ready: node.ready,
4299
+ tokenBudget: node.tokenBudget,
4300
+ tokensUsed: node.tokensUsed,
4301
+ budgetExhausted: nodeBudgetExhausted(node),
4302
+ unmetDependencies: node.dependsOn.map((dependency) => ({ key: dependency, status: byKey.get(dependency)?.status ?? "missing" })).filter((dependency) => dependency.status !== "complete")
4303
+ }));
4304
+ const incompleteDetails = incompleteNodes.map((node) => ({
4305
+ key: node.key,
4306
+ status: node.status,
4307
+ ready: node.ready
4308
+ }));
4309
+ let owner = "goal-plan";
4310
+ let cause = "goal plan has no ready nodes";
4311
+ if (planStatus !== "active") {
4312
+ owner = "goal";
4313
+ cause = `goal status is ${goal.status}`;
4314
+ } else if (pendingNodes.length === 0) {
4315
+ owner = "goal-plan";
4316
+ cause = `goal plan has no pending runnable nodes; incomplete nodes: ${summarizeLabels(incompleteDetails.map((node) => `${node.key}:${node.status}`))}`;
4317
+ } else if (pendingDetails.every((node) => node.budgetExhausted)) {
4318
+ owner = "goal-plan-node";
4319
+ cause = `all pending nodes are budget-exhausted: ${summarizeLabels(pendingDetails.map((node) => node.key))}`;
4320
+ } else {
4321
+ const missingDependencies = pendingDetails.flatMap((node) => node.unmetDependencies.filter((dependency) => dependency.status === "missing").map((dependency) => `${node.key}->${dependency.key}`));
4322
+ if (missingDependencies.length > 0) {
4323
+ owner = "goal-plan";
4324
+ cause = `pending nodes reference missing dependencies: ${summarizeLabels(missingDependencies)}`;
4325
+ } else if (pendingDetails.every((node) => node.unmetDependencies.length > 0 || node.budgetExhausted)) {
4326
+ owner = "goal-plan-node";
4327
+ const waiting = pendingDetails.filter((node) => node.unmetDependencies.length > 0).map((node) => `${node.key} waits on ${node.unmetDependencies.map((dependency) => `${dependency.key}:${dependency.status}`).join(",")}`);
4328
+ const exhausted = pendingDetails.filter((node) => node.budgetExhausted).map((node) => `${node.key} budget exhausted`);
4329
+ cause = `pending nodes are blocked by prerequisites: ${summarizeLabels([...waiting, ...exhausted])}`;
4330
+ }
4331
+ }
4332
+ const blocker = `${cause}; owner=${owner}`;
4333
+ return {
4334
+ owner,
4335
+ blocker,
4336
+ planStatus,
4337
+ rollup: rollupSummary(nodes),
4338
+ pendingNodes: pendingDetails,
4339
+ incompleteNodes: incompleteDetails
4340
+ };
4341
+ }
4229
4342
  function metadataFor(goal, node, context) {
4230
4343
  return {
4231
4344
  loopId: context?.loopId,
@@ -4286,13 +4399,14 @@ async function planGoal(store, goal, spec, model, opts) {
4286
4399
  }, { daemonLeaseId: opts.daemonLeaseId });
4287
4400
  return store.createGoalPlanNodes(goal.goalId, rawNodes, { daemonLeaseId: opts.daemonLeaseId });
4288
4401
  }
4289
- function stdoutFor(goal, nodes, evidence, validation) {
4402
+ function stdoutFor(goal, nodes, evidence, validation, diagnostics) {
4290
4403
  return JSON.stringify({
4291
4404
  goal,
4292
4405
  rollup: rollupSummary(nodes),
4293
4406
  nodes,
4294
4407
  evidence,
4295
- validation
4408
+ validation,
4409
+ diagnostics
4296
4410
  }, null, 2);
4297
4411
  }
4298
4412
  async function runGoal(store, input, opts = {}) {
@@ -4325,6 +4439,7 @@ async function runGoal(store, input, opts = {}) {
4325
4439
  let validation;
4326
4440
  let lastBlocker = "";
4327
4441
  let repeatedBlockerCount = 0;
4442
+ let lastDiagnostic;
4328
4443
  if (budgetExhausted(goal)) {
4329
4444
  goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
4330
4445
  return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted after planning", startedAt);
@@ -4335,13 +4450,13 @@ async function runGoal(store, input, opts = {}) {
4335
4450
  return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal cancelled", startedAt);
4336
4451
  }
4337
4452
  goal = store.requireGoal(goal.goalId);
4338
- nodes = store.listGoalPlanNodes(goal.goalId);
4453
+ nodes = syncReadyFlags(store, goal, store.listGoalPlanNodes(goal.goalId), opts);
4339
4454
  if (budgetExhausted(goal)) {
4340
4455
  goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
4341
4456
  return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted", startedAt);
4342
4457
  }
4343
4458
  const readyKeys = readyNodeKeys({
4344
- status: goal.status === "active" ? "active" : goal.status === "budgetLimited" ? "budgetLimited" : "blocked",
4459
+ status: planStatusForGoal(goal),
4345
4460
  nodes
4346
4461
  });
4347
4462
  if (readyKeys.length > 0) {
@@ -4440,7 +4555,9 @@ ${result.stderr}`);
4440
4555
  }
4441
4556
  continue;
4442
4557
  }
4443
- const blocker = "no ready goal nodes and goal plan is incomplete";
4558
+ const diagnostic = noReadyDiagnostic(goal, nodes);
4559
+ lastDiagnostic = diagnostic;
4560
+ const blocker = diagnostic.blocker;
4444
4561
  if (blocker === lastBlocker)
4445
4562
  repeatedBlockerCount += 1;
4446
4563
  else {
@@ -4452,15 +4569,16 @@ ${result.stderr}`);
4452
4569
  turn,
4453
4570
  phase: "status",
4454
4571
  status: repeatedBlockerCount >= 3 ? "blocked" : "active",
4455
- evidence: { blocker }
4572
+ evidence: diagnostic
4456
4573
  }, { daemonLeaseId: opts.daemonLeaseId });
4457
4574
  if (repeatedBlockerCount >= 3) {
4458
4575
  goal = store.updateGoalStatus(goal.goalId, "blocked", { daemonLeaseId: opts.daemonLeaseId });
4459
- return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation), blocker, startedAt);
4576
+ return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation, diagnostic), blocker, startedAt);
4460
4577
  }
4461
4578
  }
4462
4579
  goal = store.updateGoalStatus(goal.goalId, "usageLimited", { daemonLeaseId: opts.daemonLeaseId });
4463
- return resultFromGoal(goal, "failed", stdoutFor(goal, store.listGoalPlanNodes(goal.goalId), evidence, validation), "goal max turns exhausted", startedAt);
4580
+ const exhaustedError = lastDiagnostic?.blocker ?? (lastBlocker ? `${lastBlocker}; max turns exhausted` : "goal max turns exhausted");
4581
+ return resultFromGoal(goal, "failed", stdoutFor(goal, store.listGoalPlanNodes(goal.goalId), evidence, validation, lastDiagnostic), exhaustedError, startedAt);
4464
4582
  }
4465
4583
 
4466
4584
  // src/lib/workflow-runner.ts
@@ -0,0 +1,228 @@
1
+ # Automation Runtime Design
2
+
3
+ OpenLoops can execute workflow work that external automation systems have
4
+ already materialized, but it must not become the automation product surface.
5
+ `@hasna/automations` and `@hasna/actions` own automation specs, trigger
6
+ materialization, queue state, approvals, DLQ/replay, idempotency, and audit
7
+ evidence. OpenLoops owns workflow invocation, admission, execution, run
8
+ manifests, and provider routing once work is explicitly handed off.
9
+
10
+ ## Planned Workflow Upsert SDK
11
+
12
+ External compilers should not write OpenLoops SQLite rows directly. The stable
13
+ contract should be an idempotent CLI/SDK upsert that accepts a fully rendered
14
+ one-shot workflow loop request and returns durable refs.
15
+
16
+ Proposed SDK shape:
17
+
18
+ ```ts
19
+ type WorkflowUpsertRequest = {
20
+ idempotencyKey: string;
21
+ source: { kind: "action" | "automation" | "event"; id: string; dedupeKey?: string };
22
+ subject: { kind: "repo" | "task" | "pr" | "run"; id?: string; path?: string; url?: string };
23
+ workflow: { name: string; description?: string; steps: WorkflowStepInput[] };
24
+ loop: { name: string; schedule: { type: "once"; at: string }; machine?: LoopMachineRef };
25
+ route?: { projectPath?: string; projectGroup?: string; concurrencyGroup?: string };
26
+ execution?: AutomationExecutionPolicy;
27
+ mode?: "dry-run" | "preflight" | "commit";
28
+ dispatch?: "schedule" | "run-now" | "none";
29
+ };
30
+
31
+ type AutomationExecutionPolicy = {
32
+ mode: "standard" | "strict";
33
+ envAllowlist?: string[];
34
+ secretRefs?: Array<{ env: string; ref: string; required?: boolean }>;
35
+ allowTools?: string[];
36
+ allowCommands?: string[];
37
+ requireEnforcement?: boolean;
38
+ redactionProfile?: "default" | "strict";
39
+ };
40
+
41
+ type WorkflowUpsertResult = {
42
+ ok: boolean;
43
+ dryRun: boolean;
44
+ idempotencyKey: string;
45
+ specHash: string;
46
+ refs: {
47
+ workflowId?: string;
48
+ loopId?: string;
49
+ invocationId?: string;
50
+ workItemId?: string;
51
+ runId?: string;
52
+ manifestPath?: string;
53
+ };
54
+ action: "created" | "updated" | "reused" | "rejected";
55
+ preflight?: { ok: boolean; checks: unknown[]; error?: string };
56
+ };
57
+ ```
58
+
59
+ Required semantics:
60
+
61
+ - `mode="dry-run"` validates, canonicalizes, hashes, and returns the same JSON
62
+ shape without mutating OpenLoops state.
63
+ - `mode="preflight"` additionally checks provider binaries, machine routing,
64
+ accounts/auth profiles, prompt files, and workflow target compatibility before
65
+ commit.
66
+ - `mode="commit"` is idempotent on `idempotencyKey` plus `specHash`: identical
67
+ requests return existing refs; changed specs create a new workflow version or
68
+ one-shot loop while preserving previous run history.
69
+ - `dispatch="schedule"` stores a one-shot loop for the daemon; `run-now` claims
70
+ an immediate manual slot; `none` only materializes refs for another owner to
71
+ trigger later.
72
+ - All persisted output is redacted before storage, and returned refs are enough
73
+ for the caller to inspect, cancel, replay, or resolve the run without querying
74
+ SQLite directly.
75
+
76
+ ## CLI Surface
77
+
78
+ The planned CLI should mirror the SDK:
79
+
80
+ ```bash
81
+ loops workflows upsert-one-shot ./workflow.json \
82
+ --idempotency-key actions:<action-id>:<version> \
83
+ --source-kind action \
84
+ --source-id <action-id> \
85
+ --subject-kind repo \
86
+ --subject-path /path/to/repo \
87
+ --loop-name action-<action-id> \
88
+ --at 2026-07-01T12:00:00Z \
89
+ --mode preflight \
90
+ --dispatch schedule \
91
+ --json
92
+ ```
93
+
94
+ `--mode dry-run` must not create a database file in an empty `LOOPS_DATA_DIR`.
95
+ `--mode preflight` must report the exact provider/account/worktree check that
96
+ would fail. `--mode commit` must return the durable refs required for later
97
+ inspection and replay.
98
+
99
+ ## Planned Strict Automation Execution
100
+
101
+ Automation-generated targets need a stricter execution mode than ordinary local
102
+ agent loops. Strict mode should make execution reproducible, auditable, and
103
+ secret-safe without depending on ambient shell state.
104
+
105
+ ### Policy
106
+
107
+ `execution.mode="strict"` means:
108
+
109
+ - Do not inherit full `process.env`. Start from a minimal runtime environment:
110
+ `PATH`, `HOME`, `TMPDIR`, locale keys, OpenLoops metadata keys, and tool
111
+ config dirs explicitly produced by account/secret resolution.
112
+ - Pass only named `envAllowlist` values and `secretRefs`; never embed secret
113
+ values in workflow specs, prompts, metadata, manifests, or task comments.
114
+ - Resolve `secretRefs` at run time through the owning secret/action system and
115
+ inject them only into the child process that needs them.
116
+ - Default provider posture is safe: `configIsolation="safe"`, no
117
+ `permissionMode="bypass"`, worktree mode `required` for repo mutation, and
118
+ provider-native sandboxing where available.
119
+ - Persist stdout, stderr, error, workflow events, and manifests only after
120
+ redaction. Strict redaction must treat `env`, `secret`, `token`, `key`,
121
+ `authorization`, `stdout`, `stderr`, `error`, `prompt`, and provider raw
122
+ response payloads as sensitive by default.
123
+
124
+ ### Allowlist Enforcement
125
+
126
+ Current `allowlist` metadata is advisory. Strict automation requires real
127
+ enforcement:
128
+
129
+ - If `requireEnforcement=true`, preflight must reject providers that cannot
130
+ enforce the requested tool or command allowlist.
131
+ - Codewith/Codex should map allowed tools and commands to provider-native flags
132
+ or policy files when those surfaces are available.
133
+ - Command targets should run through a small policy wrapper that rejects
134
+ unlisted executables before `exec`.
135
+ - Providers without enforceable allowlists may still run in `standard` mode,
136
+ but strict automation should fail before storing or dispatching them.
137
+
138
+ ### CLI Surface
139
+
140
+ Planned flags:
141
+
142
+ ```bash
143
+ loops workflows upsert-one-shot ./workflow.json \
144
+ --execution-mode strict \
145
+ --env-allow PATH,HOME,TMPDIR \
146
+ --secret-ref OPENROUTER_API_KEY=hasna/openrouter/live/api-key \
147
+ --allow-command bun,test,git \
148
+ --allow-tool functions.exec_command \
149
+ --require-allowlist-enforcement \
150
+ --redaction-profile strict \
151
+ --mode preflight \
152
+ --json
153
+ ```
154
+
155
+ Strict-mode dry runs must show the effective policy and redacted env names, not
156
+ secret values. Strict-mode preflight must fail with a policy classification when
157
+ any required env, secret, account profile, provider sandbox, or allowlist
158
+ enforcement surface is missing.
159
+
160
+ ## Planned DLQ And Replay
161
+
162
+ OpenLoops already models terminal route work items and has a manual
163
+ `routes requeue` command. The automation runtime contract needs a stricter DLQ
164
+ layer for exhausted side-effecting work, because external action systems need to
165
+ distinguish "failed attempt", "dead until operator action", and "resolved".
166
+
167
+ ### States
168
+
169
+ Route work items should keep these meanings:
170
+
171
+ - `failed`: a terminal attempt failed, but policy may still allow an explicit
172
+ replay after the cause is fixed.
173
+ - `dead_letter`: automatic attempts are exhausted or the failure is classified
174
+ as unsafe to retry without operator action.
175
+ - `cancelled`: an operator or owner intentionally stopped the work.
176
+ - `succeeded`: the work completed and remains deduped history.
177
+ - `resolved`: planned external DLQ state for `@hasna/actions`; OpenLoops can
178
+ mirror it as `dead_letter` plus a resolution event until a first-class status
179
+ is added.
180
+
181
+ ### Commands
182
+
183
+ Planned command surface:
184
+
185
+ ```bash
186
+ loops dlq list --route-key actions --status dead_letter --json
187
+ loops dlq show <work-item-id> --json
188
+ loops dlq replay <work-item-id> --reason "credential rotated" --idempotency-key <new-key> --json
189
+ loops dlq resolve <work-item-id> --reason "superseded upstream" --resolution superseded --json
190
+ ```
191
+
192
+ `list` and `show` must join the work item, invocation, workflow, loop, latest
193
+ workflow run, step runs, run manifest path, classification, attempt count, and
194
+ last redacted error. `replay` must be idempotent: the same replay request with
195
+ the same new idempotency key returns the same refs; a different key records a
196
+ new replay attempt linked to the original work item. `resolve` must never run
197
+ provider code; it only records why the DLQ entry no longer needs execution.
198
+
199
+ ### Replay Semantics
200
+
201
+ - Replay requires a human-readable `--reason`; automation callers also pass the
202
+ original action id and a new replay idempotency key.
203
+ - Replay clears stale loop/workflow/run refs only for terminal work items and
204
+ never for `queued`, `admitted`, or `running` items.
205
+ - Replay preserves source, subject, manifest, and previous failure history so
206
+ audits can explain why another attempt exists.
207
+ - Replay of side-effecting work should default to `mode=preflight`; promotion
208
+ to `mode=commit` is a separate operator/API decision unless the owning
209
+ `@hasna/actions` policy marks the action safely replayable.
210
+ - If `@hasna/actions` owns the queue, OpenLoops calls the action replay API and
211
+ stores the returned action/run refs instead of fabricating queue state.
212
+
213
+ ### Dead-Letter Classification
214
+
215
+ The first implementation should classify terminal failures into:
216
+
217
+ - `preflight`: missing provider binary, account profile, worktree, prompt file,
218
+ or machine route.
219
+ - `auth`: invalid credentials or expired provider account.
220
+ - `policy`: manual gate, approval gate, no-auto tag, or strict execution mode
221
+ denial.
222
+ - `transient`: network, rate limit, provider overload, or lease loss.
223
+ - `bug`: deterministic command/test failure, schema mismatch, or code exception.
224
+ - `unknown`: fallback when no classifier is confident.
225
+
226
+ Only `transient` and explicitly marked `bug` fixes should be eligible for
227
+ automatic replay policy. `auth`, `policy`, and `preflight` need operator or
228
+ owning-system resolution evidence before replay.