@desplega.ai/agent-swarm 1.74.4 → 1.76.0

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.
Files changed (88) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +1264 -46
  3. package/package.json +2 -2
  4. package/src/be/db.ts +563 -9
  5. package/src/be/memory/edges-store.ts +69 -0
  6. package/src/be/memory/providers/sqlite-store.ts +4 -0
  7. package/src/be/memory/raters/explicit-self.ts +22 -0
  8. package/src/be/memory/raters/implicit-citation.ts +44 -0
  9. package/src/be/memory/raters/llm-client.ts +172 -0
  10. package/src/be/memory/raters/llm-summarizer.ts +218 -0
  11. package/src/be/memory/raters/llm.ts +375 -0
  12. package/src/be/memory/raters/noop.ts +14 -0
  13. package/src/be/memory/raters/registry.ts +86 -0
  14. package/src/be/memory/raters/retrieval.ts +88 -0
  15. package/src/be/memory/raters/run-server-raters.ts +97 -0
  16. package/src/be/memory/raters/store.ts +228 -0
  17. package/src/be/memory/raters/types.ts +101 -0
  18. package/src/be/memory/reranker.ts +32 -2
  19. package/src/be/memory/retrieval-store.ts +116 -0
  20. package/src/be/memory/types.ts +3 -0
  21. package/src/be/migrations/051_memory_posteriors_and_retrieval.sql +67 -0
  22. package/src/be/migrations/052_memory_edges.sql +36 -0
  23. package/src/be/migrations/053_agent_waiting_for_credentials_status.sql +61 -0
  24. package/src/be/migrations/054_agent_harness_provider.sql +21 -0
  25. package/src/be/migrations/055_agent_cred_status.sql +15 -0
  26. package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
  27. package/src/be/migrations/057_inbox_item_state.sql +27 -0
  28. package/src/be/migrations/058_task_templates.sql +31 -0
  29. package/src/be/swarm-config-guard.ts +24 -0
  30. package/src/commands/credential-wait.ts +186 -0
  31. package/src/commands/provider-credentials.ts +434 -0
  32. package/src/commands/runner.ts +253 -21
  33. package/src/hooks/hook.ts +143 -66
  34. package/src/http/agents.ts +191 -1
  35. package/src/http/config.ts +11 -1
  36. package/src/http/core.ts +5 -0
  37. package/src/http/inbox-state.ts +89 -0
  38. package/src/http/index.ts +10 -0
  39. package/src/http/memory.ts +230 -1
  40. package/src/http/sessions.ts +86 -0
  41. package/src/http/status.ts +665 -0
  42. package/src/http/task-templates.ts +51 -0
  43. package/src/http/tasks.ts +85 -5
  44. package/src/http/users.ts +134 -0
  45. package/src/prompts/memories.ts +62 -0
  46. package/src/providers/claude-adapter.ts +22 -0
  47. package/src/providers/claude-managed-adapter.ts +24 -0
  48. package/src/providers/codex-adapter.ts +43 -1
  49. package/src/providers/devin-adapter.ts +18 -0
  50. package/src/providers/index.ts +7 -0
  51. package/src/providers/opencode-adapter.ts +60 -0
  52. package/src/providers/pi-mono-adapter.ts +71 -0
  53. package/src/providers/types.ts +34 -0
  54. package/src/server.ts +2 -0
  55. package/src/slack/handlers.ts +0 -1
  56. package/src/tests/agents-harness-provider.test.ts +333 -0
  57. package/src/tests/credential-check.test.ts +367 -0
  58. package/src/tests/credential-status-api.test.ts +223 -0
  59. package/src/tests/credential-status-routing.test.ts +150 -0
  60. package/src/tests/credential-wait.test.ts +282 -0
  61. package/src/tests/harness-provider-resolution.test.ts +242 -0
  62. package/src/tests/jira-sync.test.ts +1 -1
  63. package/src/tests/memory-edges.test.ts +722 -0
  64. package/src/tests/memory-rate-endpoint.test.ts +330 -0
  65. package/src/tests/memory-rate-tool.test.ts +252 -0
  66. package/src/tests/memory-rater-e2e.test.ts +578 -0
  67. package/src/tests/memory-rater-implicit-citation.test.ts +304 -0
  68. package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
  69. package/src/tests/memory-rater-llm.test.ts +964 -0
  70. package/src/tests/memory-rater-store.test.ts +249 -0
  71. package/src/tests/memory-reranker.test.ts +161 -2
  72. package/src/tests/migration-runner-regressions.test.ts +17 -2
  73. package/src/tests/mocks/mock-llm-rater-client.ts +35 -0
  74. package/src/tests/run-server-raters.test.ts +291 -0
  75. package/src/tests/sessions.test.ts +141 -0
  76. package/src/tests/status.test.ts +843 -0
  77. package/src/tests/stop-hook-task-resolution.test.ts +98 -0
  78. package/src/tests/template-recommendations.test.ts +148 -0
  79. package/src/tests/tool-annotations.test.ts +2 -2
  80. package/src/tests/use-dismissible-card.test.ts +140 -0
  81. package/src/tools/memory-rate.ts +166 -0
  82. package/src/tools/memory-search.ts +18 -0
  83. package/src/tools/store-progress.ts +37 -0
  84. package/src/tools/swarm-config/set-config.ts +17 -1
  85. package/src/tools/tool-config.ts +1 -0
  86. package/src/types.ts +122 -1
  87. package/src/utils/harness-provider.ts +32 -0
  88. package/tsconfig.json +0 -2
@@ -0,0 +1,21 @@
1
+ -- 054_agent_harness_provider.sql
2
+ --
3
+ -- Phase 1.5 of the cloud-personalization plan
4
+ -- (thoughts/taras/plans/2026-05-08-cloud-personalization-phases-1-4.md).
5
+ --
6
+ -- Add a first-class `harness_provider` column on `agents` so each agent's
7
+ -- harness (claude / codex / pi / devin / claude-managed / opencode) is
8
+ -- queryable per-row, independent of `process.env.HARNESS_PROVIDER` at
9
+ -- worker boot.
10
+ --
11
+ -- Workers push their `HARNESS_PROVIDER` value on registration; an operator
12
+ -- can later re-assign via `PATCH /api/agents/:id/harness-provider`. The
13
+ -- worker itself does NOT yet react in real time — picked up on next worker
14
+ -- restart. Full per-agent harness with dynamic adapter loading lives in
15
+ -- Linear DES-359.
16
+ --
17
+ -- Forward-only. NULL default = backward-compat for already-registered
18
+ -- agents (their column stays NULL until they re-register or an operator
19
+ -- patches it).
20
+
21
+ ALTER TABLE agents ADD COLUMN harness_provider TEXT NULL;
@@ -0,0 +1,15 @@
1
+ -- 055_agent_cred_status.sql
2
+ --
3
+ -- Worker-self-reported credential snapshot. Pairs with `harness_provider`
4
+ -- (054): the JSON describes the agent's creds for whichever harness that
5
+ -- agent runs. NULL = unreported (worker hasn't booted yet, or
6
+ -- CRED_CHECK_DISABLE=1 was set).
7
+ --
8
+ -- The existing `credentialMissing` column (053) stays. This one is additive
9
+ -- and carries the full snapshot (ready, missing, satisfiedBy, hint,
10
+ -- liveTest, reportedAt, reportKind). Once `cred_status.missing` is proven
11
+ -- across deploys, `credentialMissing` can be retired in a later migration.
12
+ --
13
+ -- Forward-only.
14
+
15
+ ALTER TABLE agents ADD COLUMN cred_status TEXT;
@@ -0,0 +1,139 @@
1
+ -- Drop the SQL CHECK constraint on agent_tasks.source.
2
+ -- The Zod layer (`AgentTaskSourceSchema` in src/types.ts) is now the single
3
+ -- source of truth for the allowed enum, so adding a new source no longer
4
+ -- requires a forward-only migration. This makes future source additions
5
+ -- (Phase 1 of the UI chat/session experience plan) cheap.
6
+ --
7
+ -- SQLite cannot ALTER a CHECK constraint in place; we follow the table-rebuild
8
+ -- pattern from migration 043_jira_source.sql verbatim, minus the CHECK clause
9
+ -- on `source`. All other columns, defaults, indexes, and FKs are preserved
10
+ -- exactly. No data migration — existing rows remain valid.
11
+ --
12
+ -- INSERT uses an explicit column list (no `SELECT *`) to be robust against
13
+ -- column-order drift between SQLite versions and against post-043 ALTERs
14
+ -- (migration 044 added `provider` and `providerMeta`).
15
+ PRAGMA foreign_keys=off;
16
+
17
+ CREATE TABLE agent_tasks_new (
18
+ id TEXT PRIMARY KEY,
19
+ agentId TEXT,
20
+ creatorAgentId TEXT,
21
+ task TEXT NOT NULL,
22
+ status TEXT NOT NULL DEFAULT 'pending',
23
+ source TEXT NOT NULL DEFAULT 'mcp',
24
+ taskType TEXT,
25
+ tags TEXT DEFAULT '[]',
26
+ priority INTEGER DEFAULT 50,
27
+ dependsOn TEXT DEFAULT '[]',
28
+ offeredTo TEXT,
29
+ offeredAt TEXT,
30
+ acceptedAt TEXT,
31
+ rejectionReason TEXT,
32
+ slackChannelId TEXT,
33
+ slackThreadTs TEXT,
34
+ slackUserId TEXT,
35
+ mentionMessageId TEXT,
36
+ mentionChannelId TEXT,
37
+ vcsProvider TEXT,
38
+ vcsRepo TEXT,
39
+ vcsEventType TEXT,
40
+ vcsNumber INTEGER,
41
+ vcsCommentId INTEGER,
42
+ vcsAuthor TEXT,
43
+ vcsUrl TEXT,
44
+ parentTaskId TEXT,
45
+ claudeSessionId TEXT,
46
+ agentmailInboxId TEXT,
47
+ agentmailMessageId TEXT,
48
+ agentmailThreadId TEXT,
49
+ model TEXT,
50
+ scheduleId TEXT,
51
+ workflowRunId TEXT REFERENCES workflow_runs(id),
52
+ workflowRunStepId TEXT REFERENCES workflow_run_steps(id),
53
+ createdAt TEXT NOT NULL,
54
+ lastUpdatedAt TEXT NOT NULL,
55
+ finishedAt TEXT,
56
+ failureReason TEXT,
57
+ output TEXT,
58
+ progress TEXT,
59
+ notifiedAt TEXT,
60
+ dir TEXT,
61
+ outputSchema TEXT,
62
+ compactionCount INTEGER DEFAULT 0,
63
+ peakContextPercent REAL,
64
+ totalContextTokensUsed INTEGER,
65
+ contextWindowSize INTEGER,
66
+ was_paused INTEGER NOT NULL DEFAULT 0,
67
+ credentialKeySuffix TEXT,
68
+ credentialKeyType TEXT,
69
+ requestedByUserId TEXT REFERENCES users(id),
70
+ vcsInstallationId INTEGER,
71
+ vcsNodeId TEXT,
72
+ slackReplySent INTEGER DEFAULT 0,
73
+ swarmVersion TEXT,
74
+ contextKey TEXT,
75
+ provider TEXT,
76
+ providerMeta TEXT
77
+ );
78
+
79
+ INSERT INTO agent_tasks_new (
80
+ id, agentId, creatorAgentId, task, status, source, taskType, tags,
81
+ priority, dependsOn, offeredTo, offeredAt, acceptedAt, rejectionReason,
82
+ slackChannelId, slackThreadTs, slackUserId,
83
+ mentionMessageId, mentionChannelId,
84
+ vcsProvider, vcsRepo, vcsEventType, vcsNumber, vcsCommentId, vcsAuthor, vcsUrl,
85
+ parentTaskId, claudeSessionId,
86
+ agentmailInboxId, agentmailMessageId, agentmailThreadId,
87
+ model, scheduleId, workflowRunId, workflowRunStepId,
88
+ createdAt, lastUpdatedAt, finishedAt, failureReason, output, progress, notifiedAt,
89
+ dir, outputSchema, compactionCount, peakContextPercent,
90
+ totalContextTokensUsed, contextWindowSize, was_paused,
91
+ credentialKeySuffix, credentialKeyType, requestedByUserId,
92
+ vcsInstallationId, vcsNodeId, slackReplySent, swarmVersion, contextKey,
93
+ provider, providerMeta
94
+ )
95
+ SELECT
96
+ id, agentId, creatorAgentId, task, status, source, taskType, tags,
97
+ priority, dependsOn, offeredTo, offeredAt, acceptedAt, rejectionReason,
98
+ slackChannelId, slackThreadTs, slackUserId,
99
+ mentionMessageId, mentionChannelId,
100
+ vcsProvider, vcsRepo, vcsEventType, vcsNumber, vcsCommentId, vcsAuthor, vcsUrl,
101
+ parentTaskId, claudeSessionId,
102
+ agentmailInboxId, agentmailMessageId, agentmailThreadId,
103
+ model, scheduleId, workflowRunId, workflowRunStepId,
104
+ createdAt, lastUpdatedAt, finishedAt, failureReason, output, progress, notifiedAt,
105
+ dir, outputSchema, compactionCount, peakContextPercent,
106
+ totalContextTokensUsed, contextWindowSize, was_paused,
107
+ credentialKeySuffix, credentialKeyType, requestedByUserId,
108
+ vcsInstallationId, vcsNodeId, slackReplySent, swarmVersion, contextKey,
109
+ provider, providerMeta
110
+ FROM agent_tasks;
111
+
112
+ DROP TABLE agent_tasks;
113
+ ALTER TABLE agent_tasks_new RENAME TO agent_tasks;
114
+
115
+ -- Recreate every index that existed on agent_tasks (mirrors 043 + later additions):
116
+ -- 001/004/006/009/026: agentId, status, offeredTo, taskType, agentmailThreadId, scheduleId, workflowRunId
117
+ -- 031: requestedByUserId (partial)
118
+ -- 034: parentTaskId
119
+ -- 037: swarmVersion
120
+ -- 040: composite (slackChannelId, slackThreadTs, status)
121
+ -- 042: contextKey + (contextKey, status) composite
122
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_agentId ON agent_tasks(agentId);
123
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_status ON agent_tasks(status);
124
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_offeredTo ON agent_tasks(offeredTo);
125
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_taskType ON agent_tasks(taskType);
126
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_agentmailThreadId ON agent_tasks(agentmailThreadId);
127
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_schedule_id ON agent_tasks(scheduleId);
128
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_workflow_run ON agent_tasks(workflowRunId);
129
+ CREATE INDEX IF NOT EXISTS idx_tasks_requested_by ON agent_tasks(requestedByUserId) WHERE requestedByUserId IS NOT NULL;
130
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_parentTaskId ON agent_tasks(parentTaskId);
131
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_swarmVersion ON agent_tasks(swarmVersion);
132
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_slack_thread
133
+ ON agent_tasks(slackChannelId, slackThreadTs, status);
134
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_context_key
135
+ ON agent_tasks(contextKey);
136
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_context_key_status
137
+ ON agent_tasks(contextKey, status);
138
+
139
+ PRAGMA foreign_keys=on;
@@ -0,0 +1,27 @@
1
+ -- Inbox item state — per-user dismiss/snooze/done state for action-items inbox
2
+ -- buckets (approval, credential_missing, broken_task, to_read, to_start_template).
3
+ --
4
+ -- itemType is enforced via Zod (`InboxItemTypeSchema` in src/types.ts), not a
5
+ -- SQL CHECK constraint — Phase 1 lesson, lets us extend the enum without a
6
+ -- forward-only migration. Direct SQL inserts can bypass; the HTTP layer
7
+ -- (`PATCH /api/inbox-state`) is the only sanctioned writer.
8
+ --
9
+ -- itemId references the underlying entity (task id, approval-request id,
10
+ -- agent id, template id, …) but is left as a free TEXT column rather than a
11
+ -- typed FK because itemType disambiguates which table it points at.
12
+ CREATE TABLE IF NOT EXISTS inbox_item_state (
13
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
14
+ userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
15
+ itemType TEXT NOT NULL,
16
+ itemId TEXT NOT NULL,
17
+ status TEXT NOT NULL DEFAULT 'open',
18
+ snoozeUntil TEXT,
19
+ dismissedAt TEXT,
20
+ doneAt TEXT,
21
+ createdAt TEXT NOT NULL DEFAULT (datetime('now')),
22
+ lastUpdatedAt TEXT NOT NULL DEFAULT (datetime('now')),
23
+ UNIQUE(userId, itemType, itemId)
24
+ );
25
+
26
+ CREATE INDEX IF NOT EXISTS idx_inbox_item_state_userId_status
27
+ ON inbox_item_state(userId, status);
@@ -0,0 +1,31 @@
1
+ -- Task templates — "To start" bucket starters. Polymorphic from day one
2
+ -- (kind = 'task' | 'workflow' | 'schedule') so v2 can register workflow /
3
+ -- schedule starters without a follow-up migration. v1 only inserts/reads
4
+ -- kind='task' rows; the schema is shaped for v2.
5
+ --
6
+ -- The `prompt` column is NOT NULL only because v1 only ever seeds task rows;
7
+ -- a future migration can relax that when workflow/schedule starters land
8
+ -- (workflows carry workflowId in `payload`, schedules carry cron + prompt).
9
+ --
10
+ -- Table name kept as `task_templates` for v1 to match existing references
11
+ -- across the plan; v2 may rename to `quick_starts` if non-task kinds graduate.
12
+ CREATE TABLE IF NOT EXISTS task_templates (
13
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
14
+ title TEXT NOT NULL,
15
+ description TEXT NOT NULL,
16
+ prompt TEXT NOT NULL,
17
+ kind TEXT NOT NULL DEFAULT 'task' CHECK(kind IN ('task','workflow','schedule')),
18
+ payload TEXT NOT NULL DEFAULT '{}',
19
+ category TEXT,
20
+ tags TEXT NOT NULL DEFAULT '[]',
21
+ createdAt TEXT NOT NULL DEFAULT (datetime('now'))
22
+ );
23
+
24
+ CREATE INDEX IF NOT EXISTS idx_task_templates_kind ON task_templates(kind);
25
+
26
+ INSERT INTO task_templates (title, description, prompt, category, tags) VALUES
27
+ ('Refactor a file', 'Improve a file without changing behavior', 'Refactor the file at <path> for readability while preserving behavior. Run typecheck + tests after.', 'engineering', '["refactor"]'),
28
+ ('Investigate a bug', 'Reproduce, root-cause, and propose a fix', 'Investigate the following bug: <symptom>. Reproduce locally, identify the root cause, and propose a fix.', 'engineering', '["debug"]'),
29
+ ('Open a PR', 'Create a PR for the current branch', 'Open a PR from the current branch with a clear summary and test plan.', 'git', '["git","pr"]'),
30
+ ('Write tests for X', 'Cover an under-tested module', 'Write unit tests for <module>. Aim for ~80% line coverage.', 'engineering', '["test"]'),
31
+ ('Daily triage', 'Review failed tasks + pending approvals', 'Triage the action-items inbox: dismiss noise, escalate blockers, summarize unread sessions.', 'ops', '["triage"]');
@@ -1,3 +1,5 @@
1
+ import { ProviderNameSchema } from "../types";
2
+
1
3
  /**
2
4
  * Guards against storing reserved keys in the swarm_config table.
3
5
  *
@@ -23,3 +25,25 @@ export function reservedKeyError(key: string): Error {
23
25
  `Set it as an environment variable instead.`,
24
26
  );
25
27
  }
28
+
29
+ /**
30
+ * Per-key value validators run on `upsertSwarmConfig` writes via the HTTP
31
+ * config API. Use this when an invalid value would silently break workers
32
+ * (e.g. typo'd HARNESS_PROVIDER would fall back to "claude" with only a
33
+ * console.warn — the operator wouldn't see why their config was ignored).
34
+ *
35
+ * Returns a human-readable error string when the value is invalid, or
36
+ * `null` when the key has no validator or the value passes.
37
+ */
38
+ const VALIDATED_KEYS: Record<string, (value: unknown) => string | null> = {
39
+ HARNESS_PROVIDER: (value) => {
40
+ const parsed = ProviderNameSchema.safeParse(value);
41
+ if (parsed.success) return null;
42
+ return `Invalid HARNESS_PROVIDER value (must be one of: ${ProviderNameSchema.options.join(", ")})`;
43
+ },
44
+ };
45
+
46
+ export function validateConfigValue(key: string, value: unknown): string | null {
47
+ const validator = VALIDATED_KEYS[key.toUpperCase()];
48
+ return validator ? validator(value) : null;
49
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Worker-side credential wait loop.
3
+ *
4
+ * Runs once at boot, *after* the worker has registered with the API
5
+ * (`POST /api/agents`). While harness credentials are missing, the loop:
6
+ *
7
+ * 1. Calls `checkProviderCredentials(provider, process.env)` — if ready,
8
+ * returns immediately.
9
+ * 2. Otherwise calls the caller-provided `refreshEnv()` (typically
10
+ * `fetchResolvedEnv` from runner.ts) to pull `swarm_config` keys into
11
+ * `process.env`.
12
+ * 3. Re-checks; if ready, returns.
13
+ * 4. Logs a `[boot] waiting for …` line and invokes `onTick(status)` so
14
+ * callers can report state to the API.
15
+ * 5. Sleeps with exponential backoff (2s → 30s, cap configurable).
16
+ * 6. If `BOOT_MAX_WAIT_SECONDS` is set and exceeded, throws a
17
+ * `BootMaxWaitExceededError` so the runner can exit with a distinct
18
+ * code. Default 0 = wait forever.
19
+ *
20
+ * Why TS-level wait instead of bash-level fail-fast: workers running under
21
+ * `restart: unless-stopped` would otherwise loop the container forever when
22
+ * a credential is set via `swarm_config` after the first boot, because the
23
+ * entrypoint hard-exits before the process can refresh.
24
+ */
25
+
26
+ import type { CredCheckOptions, CredStatus } from "../providers/types";
27
+ import { checkProviderCredentials } from "./provider-credentials";
28
+
29
+ /** Exit code distinct from generic failures so monitoring can distinguish
30
+ * "config never arrived" from worker process crashes. Matches sysexits(3)'s
31
+ * `EX_CONFIG`.
32
+ */
33
+ export const EX_CONFIG = 78;
34
+
35
+ export class BootMaxWaitExceededError extends Error {
36
+ constructor(
37
+ public readonly elapsedSeconds: number,
38
+ public readonly lastStatus: CredStatus,
39
+ ) {
40
+ super(
41
+ `Boot wait exceeded BOOT_MAX_WAIT_SECONDS (${elapsedSeconds.toFixed(1)}s). ` +
42
+ `Still missing: ${lastStatus.missing.join(", ") || "(unknown)"}.`,
43
+ );
44
+ this.name = "BootMaxWaitExceededError";
45
+ }
46
+ }
47
+
48
+ export interface AwaitCredentialsOptions {
49
+ /** Harness provider name — picks the predicate to run. */
50
+ provider: string;
51
+ /** Pull latest swarm_config values into env. Resolves to the merged env. */
52
+ refreshEnv: () => Promise<Record<string, string | undefined>>;
53
+ /** Callback invoked on every tick — Phase 3 wires this to the status-report API. */
54
+ onTick?: (status: CredStatus, attempt: number) => void;
55
+ /** Override env source (defaults to `process.env`). */
56
+ initialEnv?: Record<string, string | undefined>;
57
+ /** Sleep helper override for tests. */
58
+ sleep?: (ms: number) => Promise<void>;
59
+ /** Clock override for tests (returns ms epoch). */
60
+ now?: () => number;
61
+ /** Forwarded to `checkProviderCredentials` (file-presence injection for codex/pi/opencode). */
62
+ credCheckOptions?: CredCheckOptions;
63
+ /** Override the default backoff config (else read from env). */
64
+ backoff?: {
65
+ initialMs?: number;
66
+ maxMs?: number;
67
+ maxWaitSeconds?: number;
68
+ };
69
+ /** Logger override (defaults to console.log). */
70
+ log?: (line: string) => void;
71
+ }
72
+
73
+ interface ResolvedBackoff {
74
+ initialMs: number;
75
+ maxMs: number;
76
+ maxWaitSeconds: number;
77
+ }
78
+
79
+ function resolveBackoff(
80
+ override: AwaitCredentialsOptions["backoff"],
81
+ env: Record<string, string | undefined>,
82
+ ): ResolvedBackoff {
83
+ const parsePositive = (raw: string | undefined, fallback: number): number => {
84
+ if (!raw) return fallback;
85
+ const n = Number(raw);
86
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
87
+ };
88
+ return {
89
+ initialMs: override?.initialMs ?? parsePositive(env.BOOT_INITIAL_BACKOFF_MS, 2000),
90
+ maxMs: override?.maxMs ?? parsePositive(env.BOOT_MAX_BACKOFF_MS, 30000),
91
+ // 0 = wait forever — the runner can override with a finite ceiling per
92
+ // worker if monitoring wants a "config never arrived" signal.
93
+ maxWaitSeconds: override?.maxWaitSeconds ?? parsePositive(env.BOOT_MAX_WAIT_SECONDS, 0),
94
+ };
95
+ }
96
+
97
+ /** Update process.env in place from a refreshed env object. */
98
+ function applyEnvUpdates(refreshed: Record<string, string | undefined>): void {
99
+ for (const [key, value] of Object.entries(refreshed)) {
100
+ if (value === undefined) {
101
+ delete process.env[key];
102
+ } else {
103
+ process.env[key] = value;
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Block until the worker's harness has its credentials.
110
+ *
111
+ * Returns the final `CredStatus` (always `ready: true`) once satisfied. The
112
+ * caller is then free to start the polling loop.
113
+ */
114
+ export async function awaitCredentials(opts: AwaitCredentialsOptions): Promise<CredStatus> {
115
+ const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
116
+ const now = opts.now ?? (() => Date.now());
117
+ const log = opts.log ?? ((line: string) => console.log(line));
118
+ const initialEnv = opts.initialEnv ?? process.env;
119
+ const backoff = resolveBackoff(opts.backoff, initialEnv);
120
+
121
+ // Fast path: already satisfied at boot.
122
+ let status = checkProviderCredentials(opts.provider, initialEnv, opts.credCheckOptions);
123
+ if (status.ready) {
124
+ log(`[boot] credentials ready (provider=${opts.provider}, satisfiedBy=${status.satisfiedBy})`);
125
+ return status;
126
+ }
127
+
128
+ const start = now();
129
+ let attempt = 0;
130
+ let delayMs = backoff.initialMs;
131
+
132
+ while (!status.ready) {
133
+ attempt += 1;
134
+
135
+ // Notify the caller (Phase 3 reports waiting_for_credentials to the API).
136
+ try {
137
+ opts.onTick?.(status, attempt);
138
+ } catch (err) {
139
+ // onTick failures must never break the wait loop — they're just
140
+ // best-effort status reporting.
141
+ log(`[boot] onTick error (non-fatal): ${err}`);
142
+ }
143
+
144
+ log(
145
+ `[boot] waiting for ${status.missing.join(", ") || "credentials"} ` +
146
+ `(attempt ${attempt}, retry in ${delayMs}ms)${status.hint ? ` — ${status.hint}` : ""}`,
147
+ );
148
+
149
+ await sleep(delayMs);
150
+
151
+ // Refresh env from swarm_config (the whole point of the loop — the
152
+ // server may have just been told about a credential).
153
+ try {
154
+ const refreshed = await opts.refreshEnv();
155
+ applyEnvUpdates(refreshed);
156
+ } catch (err) {
157
+ // Don't crash on a transient refresh failure; just retry on the next tick.
158
+ log(`[boot] env refresh failed (non-fatal): ${err}`);
159
+ }
160
+
161
+ status = checkProviderCredentials(opts.provider, process.env, opts.credCheckOptions);
162
+
163
+ if (!status.ready) {
164
+ // Exponential backoff with cap.
165
+ delayMs = Math.min(delayMs * 2, backoff.maxMs);
166
+
167
+ if (backoff.maxWaitSeconds > 0) {
168
+ const elapsedSec = (now() - start) / 1000;
169
+ if (elapsedSec >= backoff.maxWaitSeconds) {
170
+ throw new BootMaxWaitExceededError(elapsedSec, status);
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ log(
177
+ `[boot] credentials ready (provider=${opts.provider}, satisfiedBy=${status.satisfiedBy}, attempts=${attempt})`,
178
+ );
179
+ // Final tick so callers can clear the waiting state.
180
+ try {
181
+ opts.onTick?.(status, attempt);
182
+ } catch {
183
+ // best-effort
184
+ }
185
+ return status;
186
+ }