@app-vitals/agent-runtime 0.0.0-stage → 0.1.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 (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +40 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
package/boot.ts ADDED
@@ -0,0 +1,845 @@
1
+ /**
2
+ * agent/src/runtime/boot.ts
3
+ * Agent runtime boot sequence, exported as startAgent(extensions).
4
+ *
5
+ * A consumer entry (agent/src/index.ts for the coding agent) calls startAgent
6
+ * with the extension factories for its specialty — the runtime itself imports
7
+ * no specialty module, so it can be extracted and shared.
8
+ *
9
+ * Boot order:
10
+ * 1. ensureAgentHome
11
+ * 2. Health server — binds immediately so kubelet probes stay green during setup
12
+ * 3. runMiseStartup + installPlugins (defaults)
13
+ * 4. Config sync — fetch AgentConfigBundle, apply env, install agent plugins (await first)
14
+ * 5. reconcileSystemCrons — best-effort, non-fatal
15
+ * 6. Cron sync loop (60s)
16
+ * 7. Slack Bolt Socket Mode app
17
+ * 8. Graceful SIGTERM/SIGINT shutdown
18
+ */
19
+
20
+ import { join } from "node:path";
21
+ import { initSentry } from "@app-vitals/agent-lib/sentry";
22
+ import * as Sentry from "@sentry/bun";
23
+ import { WebClient } from "@slack/web-api";
24
+ import nodeCron from "node-cron";
25
+ import { agentAccountStatusRef } from "./agent-account-status-ref.ts";
26
+ import {
27
+ type AgentExtensionContext,
28
+ type AgentExtensionFactory,
29
+ composeExtensions,
30
+ planCronSchedule,
31
+ runPeriodicPasses,
32
+ } from "./agent-extension.ts";
33
+ import { agentReposRef } from "./agent-repos-ref.ts";
34
+ import {
35
+ agentSlackMembershipRef,
36
+ resolveSlackMembership,
37
+ } from "./agent-slack-membership-ref.ts";
38
+ import { agentTrialExpiryRef } from "./agent-trial-expiry-ref.ts";
39
+ import { applyEnvToProcess } from "./anthropic-federation-guard.ts";
40
+ import { createChatPoller } from "./chat-poller.ts";
41
+ import {
42
+ HttpChatTokenReporter,
43
+ NoopChatTokenReporter,
44
+ } from "./chat-token-reporter.ts";
45
+ import {
46
+ createRunClaude,
47
+ type EarlySessionIdCallback,
48
+ type ProgressCallback,
49
+ setLiveClaudeConfig,
50
+ } from "./claude.ts";
51
+ import { bunExec, detectClaudeCodeVersion } from "./claude-version.ts";
52
+ import { cleanupAfterDaysRef } from "./cleanup-after-days-ref.ts";
53
+ import { SystemClock } from "./clock.ts";
54
+ import { createConfig } from "./config.ts";
55
+ import { createContextStampReader } from "./context-stamp.ts";
56
+ import { reportCronFailure } from "./cron-failure-reporter.ts";
57
+ import type { CronHandlerDeps } from "./cron-handler.ts";
58
+ import { handleCronRequest } from "./cron-handler.ts";
59
+ import {
60
+ HttpCronRunReporter,
61
+ NoopCronRunReporter,
62
+ } from "./cron-run-reporter.ts";
63
+ import { markdownToSlack } from "./format.ts";
64
+ import { buildGitHubAuthDeps } from "./github-auth-deps.ts";
65
+ import {
66
+ githubAuthActiveRef,
67
+ githubInstallationsManagerRef,
68
+ reconcileGitHubInstallations,
69
+ startGitHubAuthIfPossible,
70
+ } from "./github-auth-startup.ts";
71
+ import {
72
+ HttpGitHubInstallationsReporter,
73
+ NoopGitHubInstallationsReporter,
74
+ } from "./github-installations-reporter.ts";
75
+ import {
76
+ claudeVersionState,
77
+ DEFAULT_HEALTH_PORT,
78
+ markSlackConnected,
79
+ markSlackDisconnected,
80
+ startHealthServer,
81
+ } from "./health.ts";
82
+ import { HttpChatServiceClient } from "./http-chat-service-client.ts";
83
+ import { buildLogPrefix } from "./log-prefix.ts";
84
+ import { validatePiperVoice } from "./piper-voice.ts";
85
+ import {
86
+ createFileSessionStore,
87
+ startSessionPruner,
88
+ threadKey,
89
+ } from "./sessions.ts";
90
+ import { ensureAgentHome, installPlugins, runMiseStartup } from "./setup.ts";
91
+ import { setupGitHubAuth } from "./setup-github-auth.ts";
92
+ import { HttpShipwrightRuntimeClient } from "./shipwright-runtime-client.ts";
93
+ import { createSlackApp, hasSlackCredentials } from "./slack.ts";
94
+ import {
95
+ type StartableSlackApp,
96
+ slackClientRef,
97
+ startSlackIfPossible,
98
+ } from "./slack-startup.ts";
99
+ import { defaultOwnerOf } from "./spawn-env.ts";
100
+ import { sendBackOnlineDm } from "./startup-dm.ts";
101
+ import {
102
+ buildProductionDeps as buildSyncConfigCloneDeps,
103
+ syncClonedRepos,
104
+ } from "./sync-config-clone.ts";
105
+ import { resolveDisplayName, resolveUserEmail } from "./users.ts";
106
+ import { synthesizeSpeech, transcribeAudio } from "./voice.ts";
107
+ import {
108
+ buildProductionDeps as buildWorktreeReaperDeps,
109
+ reconcileStaleWorktrees,
110
+ } from "./worktree-reaper.ts";
111
+
112
+ export async function startAgent(
113
+ extensions: AgentExtensionFactory[] = [],
114
+ ): Promise<void> {
115
+ const agentHome = process.env.AGENT_HOME ?? "/data/agent-home";
116
+ const { config } = createConfig(agentHome);
117
+
118
+ // Discovery-based startup validation: fail loudly now (log) rather than
119
+ // silently at speak time if PIPER_VOICE doesn't match a baked voice.
120
+ const resolvedPiperVoice = validatePiperVoice(config.voice.piperVoice);
121
+ console.log(`[agent] piper voice resolved: ${resolvedPiperVoice}`);
122
+
123
+ // ─── Agent ID (hoisted for use in console monkeypatch) ────────────────────────
124
+
125
+ const agentId = config.shipwright.agentId;
126
+
127
+ // ─── Timestamp + agent ID prefix ──────────────────────────────────────────────
128
+
129
+ for (const level of ["log", "warn", "error"] as const) {
130
+ const orig = console[level].bind(console);
131
+ console[level] = (...args: unknown[]) => {
132
+ const timestamp = new Date().toISOString();
133
+ const prefix = buildLogPrefix(agentId, timestamp);
134
+ orig(prefix, ...args);
135
+ };
136
+ }
137
+
138
+ // ─── Sentry (no-op when SENTRY_DSN is unset) ─────────────────────────────────
139
+ // Placed AFTER the console monkeypatch above so Sentry's consoleLoggingIntegration
140
+ // wraps on top of it — this captures clean log args in Sentry while local
141
+ // output keeps its timestamp prefix.
142
+
143
+ initSentry({ service: "agent", agentId });
144
+
145
+ // ─── Step 1: Agent home ───────────────────────────────────────────────────────
146
+
147
+ ensureAgentHome(config.paths.home);
148
+ console.log(`[agent] agent home initialized: ${config.paths.home}`);
149
+
150
+ // ─── Step 2: Health server ────────────────────────────────────────────────────
151
+ // Bind before mise/plugin install so kubelet liveness probes don't time out
152
+ // during a slow toolchain install (e.g. compiling Python from source).
153
+
154
+ const slackClock = SystemClock();
155
+ const sessions = createFileSessionStore(config.paths.sessions);
156
+ // DTW-1.3: make the store's TTL prune real. Slack thread keys are re-read (so
157
+ // they expire lazily via get()), but a dev-task dispatch's per-dispatch session
158
+ // key is written once and never read again — its owning resume loop clears it
159
+ // on exit, and this sweep is the backstop for the ones an abrupt process kill
160
+ // strands.
161
+ startSessionPruner(sessions, "sessions.json");
162
+
163
+ // Slack is optional (see Step 7 + startSlackIfPossible in slack-startup.ts).
164
+ // Building a WebClient around an empty token would make every downstream
165
+ // call fail with `invalid_auth` instead of being skipped, so a chat-only
166
+ // agent gets no client at all and callers branch on `undefined`.
167
+ //
168
+ // buildSlackAppConfig() reads live process.env rather than the static
169
+ // `config` snapshot taken above at module-load time — SLK-1.1: credentials
170
+ // saved to the admin DB after boot only reach this process via syncConfig()'s
171
+ // `applyEnvToProcess(bundle.env)`, so re-reading `config.slack.*`
172
+ // here would keep seeing the boot-time (absent) values forever.
173
+ function buildSlackAppConfig() {
174
+ return {
175
+ botToken: process.env.SLACK_BOT_TOKEN ?? "",
176
+ appToken: process.env.SLACK_APP_TOKEN ?? "",
177
+ signingSecret: process.env.SLACK_SIGNING_SECRET ?? "",
178
+ };
179
+ }
180
+ const runner = createRunClaude(
181
+ Bun.spawn,
182
+ sessions,
183
+ undefined,
184
+ config.paths.workspace,
185
+ process.env.SENTRY_DSN ? Sentry : undefined,
186
+ undefined,
187
+ undefined,
188
+ undefined,
189
+ config.claude.timeoutMs,
190
+ config.claude.idleTimeoutMs,
191
+ );
192
+
193
+ const cronRunReporter =
194
+ config.shipwright.apiUrl &&
195
+ config.shipwright.apiKey &&
196
+ config.shipwright.agentId
197
+ ? new HttpCronRunReporter({
198
+ apiUrl: config.shipwright.apiUrl,
199
+ agentId: config.shipwright.agentId,
200
+ apiKey: config.shipwright.apiKey,
201
+ })
202
+ : undefined;
203
+
204
+ const chatTokenReporter =
205
+ config.shipwright.apiUrl &&
206
+ config.shipwright.apiKey &&
207
+ config.shipwright.agentId
208
+ ? new HttpChatTokenReporter({
209
+ apiUrl: config.shipwright.apiUrl,
210
+ agentId: config.shipwright.agentId,
211
+ apiKey: config.shipwright.apiKey,
212
+ })
213
+ : new NoopChatTokenReporter();
214
+
215
+ const githubInstallationsReporter =
216
+ config.shipwright.apiUrl &&
217
+ config.shipwright.apiKey &&
218
+ config.shipwright.agentId
219
+ ? new HttpGitHubInstallationsReporter({
220
+ apiUrl: config.shipwright.apiUrl,
221
+ agentId: config.shipwright.agentId,
222
+ apiKey: config.shipwright.apiKey,
223
+ })
224
+ : new NoopGitHubInstallationsReporter();
225
+
226
+ // Always-loaded-context stamp (context-stamp.ts): fingerprints the workspace
227
+ // CLAUDE.md + its imports + no-`paths` rules + the plugin version, read once
228
+ // per run so cron-run rows can be grouped by what the model was given. The
229
+ // plugin.json path resolves to the in-image copy of the shipwright plugin,
230
+ // whose version the Dockerfile stamps at build time (sync-version.ts).
231
+ const contextStamp = createContextStampReader({
232
+ workspace: config.paths.workspace,
233
+ pluginJsonPath: join(
234
+ import.meta.dir,
235
+ "..",
236
+ "..",
237
+ "..",
238
+ "plugins",
239
+ "shipwright",
240
+ ".claude-plugin",
241
+ "plugin.json",
242
+ ),
243
+ claudeCodeVersion: () => claudeVersionState.version,
244
+ });
245
+
246
+ const cronDeps: CronHandlerDeps = {
247
+ // SLK-1.1: a getter, not a plain field — Slack may start after boot (see
248
+ // slack-startup.ts's startSlackIfPossible()), so cron dispatch must read
249
+ // the live slackClientRef on every fire rather than closing over a
250
+ // boot-time `undefined` forever.
251
+ get slack() {
252
+ return slackClientRef.get();
253
+ },
254
+ runner: (
255
+ message: string,
256
+ onProgress?: ProgressCallback,
257
+ extraEnv?: Record<string, string>,
258
+ onEarlySessionId?: EarlySessionIdCallback,
259
+ ) =>
260
+ runner(
261
+ message,
262
+ undefined,
263
+ onProgress,
264
+ undefined,
265
+ extraEnv,
266
+ onEarlySessionId,
267
+ ),
268
+ formatter: markdownToSlack,
269
+ onSession: async (channel: string, ts: string, sessionId: string) => {
270
+ await sessions.set(threadKey(channel, ts), sessionId);
271
+ },
272
+ synthesizeSpeechFn: synthesizeSpeech,
273
+ voiceConfig: config.voice,
274
+ workspace: config.paths.workspace,
275
+ alertsChannel: config.alerts.channel,
276
+ cronRunReporter,
277
+ agentId: config.shipwright.agentId,
278
+ contextStamp,
279
+ // GOR-1.3: cron spawns carry the default installation's org as the gh hint.
280
+ getDefaultOwner: () =>
281
+ defaultOwnerOf(githubInstallationsManagerRef.get()?.getState?.()),
282
+ };
283
+
284
+ // ARD-3.1: the specialty extension seam (agent-extension.ts). The consumer
285
+ // entry (index.ts for the coding agent) passes the extension factories; they
286
+ // are built here (after every dep they need exists, before config sync / cron
287
+ // sync call their hooks) and composed into the single extension the rest of
288
+ // boot talks to.
289
+ const extensionContext: AgentExtensionContext = {
290
+ config,
291
+ runner,
292
+ sessions,
293
+ cronRunReporter,
294
+ contextStamp,
295
+ sentryClient: process.env.SENTRY_DSN ? Sentry : undefined,
296
+ };
297
+ const extension = composeExtensions(
298
+ extensions.map((createExtension) => createExtension(extensionContext)),
299
+ );
300
+
301
+ // SLK-1.1: shared Slack-start deps, built once so both the Step 4
302
+ // (config-sync) and Step 7 (boot) call sites of startSlackIfPossible()
303
+ // operate on the same module-scope `app`/client-ref state — a start from
304
+ // either call site is visible to the other via isAppStarted()/slackClientRef.
305
+ let app: ReturnType<typeof createSlackApp> | undefined;
306
+
307
+ function buildSlackApp() {
308
+ return createSlackApp(
309
+ (
310
+ message: string,
311
+ sessionKey?: string,
312
+ onProgress?: ProgressCallback,
313
+ extraEnv?: Record<string, string>,
314
+ ) => runner(message, sessionKey, onProgress, undefined, extraEnv),
315
+ markdownToSlack,
316
+ threadKey,
317
+ undefined, // appFactory — default Bolt App
318
+ buildSlackAppConfig(),
319
+ process.env.SENTRY_DSN ? Sentry : undefined,
320
+ undefined, // fileDownloaderFn — default
321
+ config.voice,
322
+ undefined, // transcribeAudioFn — default
323
+ synthesizeSpeech,
324
+ (userId, client) => resolveDisplayName(userId, client),
325
+ undefined, // botUserId — resolved by Bolt
326
+ undefined, // conversationsRepliesFn — default
327
+ (key) => sessions.get(key),
328
+ undefined, // blocksConverter — default
329
+ chatTokenReporter,
330
+ (userId, client) => resolveUserEmail(userId, client),
331
+ agentSlackMembershipRef,
332
+ agentTrialExpiryRef,
333
+ agentAccountStatusRef,
334
+ );
335
+ }
336
+
337
+ const slackStartDeps = {
338
+ buildSlackConfig: buildSlackAppConfig,
339
+ hasSlackCredentials,
340
+ isAppStarted: () => app !== undefined,
341
+ createSlackApp: buildSlackApp,
342
+ setApp: (started: StartableSlackApp) => {
343
+ app = started as ReturnType<typeof createSlackApp>;
344
+ },
345
+ createSlackClient: () => new WebClient(buildSlackAppConfig().botToken),
346
+ setSlackClient: slackClientRef.set,
347
+ markSlackConnected,
348
+ sendBackOnlineDm: (client: WebClient) =>
349
+ sendBackOnlineDm(client, config.owner.user),
350
+ };
351
+
352
+ // ABF-2.1: shared GitHub-App-auth-start deps, built once so syncConfig()'s
353
+ // retry call site (below) reuses the same live process.env reference and
354
+ // production wiring (spawnSync/writeToken/tokenPath/credentialHelperPath)
355
+ // as entrypoint-main.ts's one-shot boot call — see github-auth-deps.ts.
356
+ // Unlike Slack, there is no boot-time call site here: entrypoint-main.ts
357
+ // already runs setupGitHubAuth() once at boot in its own (separate)
358
+ // process, and this process's first syncConfig() tick (awaited below,
359
+ // before Step 5) is this process's own "boot" for GitHub App auth.
360
+ const GITHUB_AUTH_SCRIPTS_BIN = join(
361
+ import.meta.dir,
362
+ "..",
363
+ "..",
364
+ "scripts",
365
+ "bin",
366
+ );
367
+ const githubAuthStartDeps = {
368
+ env: process.env as Record<string, string | undefined>,
369
+ isActive: githubAuthActiveRef.isActive,
370
+ markActive: () => githubAuthActiveRef.setActive(true),
371
+ setupGitHubAuth: () =>
372
+ setupGitHubAuth(
373
+ buildGitHubAuthDeps(agentHome, GITHUB_AUTH_SCRIPTS_BIN, {
374
+ withInstallations: true,
375
+ installationsReporter: githubInstallationsReporter,
376
+ }),
377
+ ),
378
+ };
379
+
380
+ // RSF-4.1: shared auto-clone deps for syncConfig()'s repo-clone step, built
381
+ // once so reposDir (join(config.paths.workspace, "repos") — the convention
382
+ // confirmed by setup.ts/worktree-reaper.ts/pr-state-reconciler.ts/
383
+ // check-helpers.ts) and getScopedRepos (agentReposRef.get, read live on every
384
+ // call) aren't re-wired per tick.
385
+ const syncConfigCloneDeps = buildSyncConfigCloneDeps({
386
+ getScopedRepos: agentReposRef.get,
387
+ reposDir: join(config.paths.workspace, "repos"),
388
+ });
389
+
390
+ const healthPort = Number(
391
+ process.env.SHIPWRIGHT_HEALTH_PORT ?? DEFAULT_HEALTH_PORT,
392
+ );
393
+ claudeVersionState.version = await detectClaudeCodeVersion(bunExec);
394
+ console.log(`[agent] claude code version: ${claudeVersionState.version}`);
395
+ startHealthServer(
396
+ healthPort,
397
+ cronDeps,
398
+ slackClock,
399
+ undefined,
400
+ process.env.SENTRY_DSN ? Sentry : undefined,
401
+ );
402
+ console.log(`[agent] health server on port ${healthPort}`);
403
+
404
+ // ─── Step 3: mise + default plugins ──────────────────────────────────────────
405
+
406
+ await runMiseStartup(config.paths.home);
407
+ console.log("[agent] mise startup complete");
408
+
409
+ await installPlugins();
410
+ console.log("[agent] default plugin install complete");
411
+
412
+ // ─── Runtime client ───────────────────────────────────────────────────────────
413
+
414
+ const runtimeClient =
415
+ config.shipwright.apiUrl && config.shipwright.apiKey
416
+ ? new HttpShipwrightRuntimeClient({
417
+ apiUrl: config.shipwright.apiUrl,
418
+ apiKey: config.shipwright.apiKey,
419
+ })
420
+ : null;
421
+
422
+ // ─── Step 4: Config sync ──────────────────────────────────────────────────────
423
+
424
+ if (runtimeClient && agentId) {
425
+ let configNotFoundLogged = false;
426
+
427
+ async function syncConfig() {
428
+ if (!runtimeClient || !agentId) return;
429
+ try {
430
+ const bundle = await runtimeClient.getAgentConfigBundle(agentId);
431
+ configNotFoundLogged = false;
432
+
433
+ // Apply env vars — log changed keys (mask values). WIF-2.1: under
434
+ // federation the Anthropic credential keys never reach process.env,
435
+ // even if a stale admin still serves them.
436
+ const changed = applyEnvToProcess(bundle.env);
437
+ if (changed.length > 0) {
438
+ console.log(`[config-sync] updated: ${changed.join(", ")}`);
439
+ }
440
+
441
+ // Sync allowed tools
442
+ const allowedTools = bundle.allowedTools ?? [];
443
+ if (allowedTools.length > 0) {
444
+ process.env.AGENT_ALLOWED_TOOLS = JSON.stringify(allowedTools);
445
+ }
446
+
447
+ // Push new config into the live claude runner
448
+ setLiveClaudeConfig({
449
+ model: process.env.ANTHROPIC_MODEL ?? config.claude.model,
450
+ fallbackModel: process.env.ANTHROPIC_FALLBACK_MODEL,
451
+ effortLevel: process.env.ANTHROPIC_EFFORT_LEVEL,
452
+ allowedTools,
453
+ });
454
+
455
+ // Install agent-specific plugins from bundle (non-fatal)
456
+ if (bundle.plugins?.length) {
457
+ await installPlugins(undefined, undefined, bundle.plugins).catch(
458
+ (err) =>
459
+ console.warn(
460
+ "[config-sync] agent plugin install failed (non-fatal):",
461
+ (err as Error).message,
462
+ ),
463
+ );
464
+ }
465
+
466
+ // Sync the agent's scoped repos live ref
467
+ agentReposRef.set(bundle.repos);
468
+
469
+ // MGI-2.4: re-run GitHub App installation discovery against the new
470
+ // scope — a no-op until App auth has activated via the installations
471
+ // manager (startGitHubAuthIfPossible below). Never starts a second
472
+ // refresh interval; reconcile() itself never throws.
473
+ await reconcileGitHubInstallations(
474
+ githubInstallationsManagerRef,
475
+ agentReposRef,
476
+ );
477
+
478
+ // RSF-4.1: auto-clone any repo that's newly configured but not yet on
479
+ // disk — reuses computeMissingClones()'s existing skip-if-exists plan
480
+ // (the same planner the manual agent-workspace-pull CLI and hitl.ts
481
+ // already use) against the real repos/ dir. Awaited within this tick
482
+ // (not backgrounded) per explicit product decision — but the `gh`
483
+ // child process itself is spawned asynchronously, so the clone never
484
+ // blocks this process's event loop (and therefore never stalls the
485
+ // health-probe server sharing it).
486
+ // syncClonedRepos() itself catches and logs each repo's clone failure
487
+ // without throwing; this try/catch is defense-in-depth against an
488
+ // unexpected failure in the plan computation itself (e.g. a bad
489
+ // exists() call), so one bad tick's repo-clone step can never prevent
490
+ // the rest of this tick's config sync (or crash the process) — a
491
+ // repo that fails to clone simply stays missing and is retried next
492
+ // tick.
493
+ try {
494
+ await syncClonedRepos(syncConfigCloneDeps);
495
+ } catch (err) {
496
+ console.error(
497
+ "[config-sync] repo clone sync failed (non-fatal):",
498
+ err instanceof Error ? err.message : String(err),
499
+ );
500
+ }
501
+
502
+ // Specialty extension: the coding extension syncs the PR-policy refs
503
+ // (review/patch author allowlists, allow-self-review, cleanup-merged-
504
+ // worktrees) here.
505
+ extension.onConfigSync?.(bundle);
506
+
507
+ // Sync the agent's Slack-membership-restriction live ref
508
+ agentSlackMembershipRef.set(
509
+ resolveSlackMembership(
510
+ bundle.restrictSlackToMembers,
511
+ bundle.memberEmails,
512
+ ),
513
+ );
514
+
515
+ // Sync the agent's trial-expiry live ref (ATE-3.1) — read by
516
+ // agent/src/runtime/slack.ts's isTrialExpired() gate to block Slack access
517
+ // once the trial has passed.
518
+ agentTrialExpiryRef.set(
519
+ bundle.trialExpiresAt ? new Date(bundle.trialExpiresAt) : null,
520
+ );
521
+
522
+ // Sync the agent's owning-account status live ref (SSP-8.3) — read by
523
+ // agent/src/runtime/slack.ts's isAccountPaused() gate to pause Slack access
524
+ // while the account is suspended or trial_expired.
525
+ agentAccountStatusRef.set(bundle.accountStatus ?? null);
526
+
527
+ // Sync the agent's cleanup-after-days live ref (APM-1.6) — read by the
528
+ // runtime's worktree reaper.
529
+ cleanupAfterDaysRef.set(bundle.cleanupAfterDays);
530
+ } catch (err) {
531
+ if (
532
+ (err as { statusCode?: number }).statusCode === 404 &&
533
+ !configNotFoundLogged
534
+ ) {
535
+ console.log(
536
+ "[config-sync] no config bundle found — skipping env sync",
537
+ );
538
+ configNotFoundLogged = true;
539
+ return;
540
+ }
541
+ console.error(
542
+ "[config-sync] failed to fetch config bundle:",
543
+ err instanceof Error ? err.message : String(err),
544
+ );
545
+ return;
546
+ }
547
+
548
+ // SLK-1.1: retry Slack startup on every successful tick — a no-op unless
549
+ // credentials are newly complete against live process.env AND Bolt
550
+ // hasn't already started (see startSlackIfPossible's own guards). Kept
551
+ // in its own try/catch, separate from the fetch/env-sync try above, so a
552
+ // Bolt start failure (e.g. a bad token that fails Socket Mode auth) is
553
+ // logged distinctly rather than being misreported as a config-bundle
554
+ // fetch failure — and so it can never mask an already-successful env
555
+ // sync that happened earlier in this same tick.
556
+ try {
557
+ if (await startSlackIfPossible(slackStartDeps)) {
558
+ console.log("[config-sync] Slack app started — running");
559
+ }
560
+ } catch (err) {
561
+ console.error(
562
+ "[config-sync] Slack startup failed:",
563
+ err instanceof Error ? err.message : String(err),
564
+ );
565
+ }
566
+
567
+ // ABF-2.1: retry GitHub App auth setup on every successful tick — a
568
+ // no-op unless GH_APP_ID/GH_APP_PRIVATE_KEY are present in live
569
+ // process.env AND setup hasn't already activated in this process (it
570
+ // stays inactive — and is retried — while there is no
571
+ // GH_APP_INSTALLATION_ID pin and no usable in-scope installation) (see startGitHubAuthIfPossible's own
572
+ // guards). Kept in its own try/catch, separate from both the
573
+ // fetch/env-sync try above and the Slack-retry try above, so a GitHub
574
+ // auth start failure is logged distinctly rather than being
575
+ // misreported as either of those, and so it can never mask an
576
+ // already-successful env sync or Slack start earlier in this same tick.
577
+ try {
578
+ if (await startGitHubAuthIfPossible(githubAuthStartDeps)) {
579
+ console.log("[config-sync] GitHub App auth configured — running");
580
+ }
581
+ } catch (err) {
582
+ console.error(
583
+ "[config-sync] GitHub App auth startup failed:",
584
+ err instanceof Error ? err.message : String(err),
585
+ );
586
+ }
587
+ }
588
+
589
+ // Await first sync so ANTHROPIC_API_KEY is set before Slack starts
590
+ await syncConfig();
591
+ setInterval(() => void syncConfig(), 60_000);
592
+ console.log("[agent] config sync started (60s interval)");
593
+ }
594
+
595
+ // ─── Step 5: reconcileSystemCrons — best-effort ───────────────────────────────
596
+
597
+ if (runtimeClient && agentId) {
598
+ try {
599
+ await runtimeClient.reconcileSystemCrons(agentId);
600
+ console.log("[agent] system crons reconciled");
601
+ } catch (err) {
602
+ console.error(
603
+ "[agent] reconcileSystemCrons failed (non-fatal):",
604
+ err instanceof Error ? err.message : String(err),
605
+ );
606
+ }
607
+ }
608
+
609
+ // ─── Step 5b: Periodic passes — self-heal stale state ─────────────────────────
610
+ // Deliberately its own interval, independent of syncConfig/syncCrons above and
611
+ // the loop-orchestrator's per-tick candidate collection: these only need to run
612
+ // every 30-60 min, not every minute. Each pass has its own deps (built lazily on
613
+ // first tick so a not-yet-ready workspace/GH auth at boot can't crash agent
614
+ // startup) and its own try/catch (see runPeriodicPasses) so one failing pass
615
+ // never blocks the others.
616
+ //
617
+ // WTR-1.4's reconcileStaleWorktrees — a pure-filesystem sweep that force-removes
618
+ // worktrees/<repo>-<branch> dirs older than cleanup_after_days — is a runtime
619
+ // pass (ARD-3.1): it runs first, before the extension's periodic passes (the
620
+ // coding extension's PR-state/review-state/claim-invariant/census passes). The
621
+ // passes are independent, so the reaper moving ahead of them is the one
622
+ // pass-order change in this refactor.
623
+ if (runtimeClient && agentId) {
624
+ let worktreeReaperDeps:
625
+ | ReturnType<typeof buildWorktreeReaperDeps>
626
+ | undefined;
627
+
628
+ const runtimePasses = [
629
+ {
630
+ name: "worktree-reaper",
631
+ logPrefix: "[pr-state-reconciler:worktree-reaper]",
632
+ run: async () => {
633
+ worktreeReaperDeps ??= buildWorktreeReaperDeps({
634
+ getScopedRepos: agentReposRef.get,
635
+ });
636
+ await reconcileStaleWorktrees(worktreeReaperDeps);
637
+ },
638
+ },
639
+ ];
640
+
641
+ async function runPeriodicTick() {
642
+ await runPeriodicPasses([
643
+ ...runtimePasses,
644
+ ...(extension.periodicPasses ?? []),
645
+ ]);
646
+ }
647
+
648
+ const reconcilerIntervalMs = Number(
649
+ process.env.SHIPWRIGHT_PR_STATE_RECONCILER_INTERVAL_MS ?? 45 * 60_000,
650
+ );
651
+ setInterval(() => void runPeriodicTick(), reconcilerIntervalMs);
652
+ console.log(
653
+ `[agent] PR state reconciler started (${reconcilerIntervalMs}ms interval)`,
654
+ );
655
+ }
656
+
657
+ // ─── Step 6: Cron sync loop ───────────────────────────────────────────────────
658
+
659
+ const cronTasks = new Map<string, ReturnType<typeof nodeCron.schedule>>();
660
+
661
+ if (runtimeClient && agentId) {
662
+ async function syncCrons() {
663
+ if (!runtimeClient || !agentId) return;
664
+ let jobs: Awaited<ReturnType<typeof runtimeClient.listAgentCronJobs>>;
665
+ try {
666
+ jobs = await runtimeClient.listAgentCronJobs(agentId);
667
+ } catch (err) {
668
+ console.error(
669
+ "[cron-sync] failed to fetch cron jobs:",
670
+ err instanceof Error ? err.message : String(err),
671
+ );
672
+ return;
673
+ }
674
+
675
+ // Hand the extension the fresh job list (every tick, regardless of whether
676
+ // any job gets (re)scheduled below — the coding extension keeps its live
677
+ // loop jobs ref current this way), then classify this agent's own job
678
+ // list: which jobs get an independent schedule, and with which dispatch
679
+ // kind (loop vs. generic). See planCronSchedule in agent-extension.ts and
680
+ // the coding extension's loop-cron-classifier.ts for the full decision.
681
+ const scheduled = planCronSchedule(extension, jobs);
682
+ const desired = new Map(scheduled.map((entry) => [entry.job.id, entry]));
683
+
684
+ // Cancel removed/disabled jobs
685
+ for (const [id, task] of cronTasks) {
686
+ if (!desired.has(id)) {
687
+ task.stop();
688
+ cronTasks.delete(id);
689
+ console.log(`[cron-sync] unscheduled ${id}`);
690
+ }
691
+ }
692
+
693
+ // Schedule new jobs
694
+ for (const [id, entry] of desired) {
695
+ if (!cronTasks.has(id)) {
696
+ const { job, dispatch } = entry;
697
+ const task = nodeCron.schedule(job.schedule, async () => {
698
+ console.log(`[cron] firing job ${id}`);
699
+ try {
700
+ if (dispatch === "loop") {
701
+ if (!extension.dispatchLoop) {
702
+ throw new Error(
703
+ `cron job ${id} classified as loop but the agent extension has no dispatchLoop`,
704
+ );
705
+ }
706
+ await extension.dispatchLoop(id);
707
+ } else {
708
+ await handleCronRequest(
709
+ {
710
+ jobId: id,
711
+ prompt: job.prompt,
712
+ channel: job.channel ?? undefined,
713
+ user: job.user ?? undefined,
714
+ silent: job.silent,
715
+ preCheck: job.preCheck ?? undefined,
716
+ },
717
+ cronDeps,
718
+ );
719
+ }
720
+ } catch (err) {
721
+ await reportCronFailure(id, err, {
722
+ cronRunReporter: cronRunReporter ?? new NoopCronRunReporter(),
723
+ sentryClient: process.env.SENTRY_DSN ? Sentry : undefined,
724
+ clock: SystemClock(),
725
+ });
726
+ }
727
+ });
728
+ cronTasks.set(id, task);
729
+ console.log(`[cron-sync] scheduled ${id} (${job.schedule})`);
730
+ }
731
+ }
732
+ }
733
+
734
+ void syncCrons();
735
+ setInterval(() => void syncCrons(), 60_000);
736
+ console.log("[agent] cron sync started (60s interval)");
737
+ }
738
+
739
+ // ─── Step 6b: Chat poll loop ──────────────────────────────────────────────────
740
+ // Start when both SHIPWRIGHT_CHAT_SERVICE_URL and SHIPWRIGHT_CHAT_SERVICE_TOKEN
741
+ // are set. Uses a separate session store (chat-sessions.json) for per-thread
742
+ // Claude session continuity across restarts.
743
+
744
+ if (config.chat.serviceUrl && config.chat.serviceToken) {
745
+ const chatSessions = createFileSessionStore(config.paths.chatSessions);
746
+ const chatRunner = createRunClaude(
747
+ Bun.spawn,
748
+ chatSessions,
749
+ undefined,
750
+ config.paths.workspace,
751
+ process.env.SENTRY_DSN ? Sentry : undefined,
752
+ undefined,
753
+ undefined,
754
+ undefined,
755
+ config.claude.timeoutMs,
756
+ config.claude.idleTimeoutMs,
757
+ );
758
+ const chatClient = new HttpChatServiceClient({
759
+ baseUrl: config.chat.serviceUrl,
760
+ token: config.chat.serviceToken,
761
+ });
762
+ const chatPoller = createChatPoller({
763
+ client: chatClient,
764
+ runner: chatRunner,
765
+ intervalMs: config.chat.pollIntervalMs ?? 5_000,
766
+ workspaceDir: config.paths.workspace,
767
+ transcribeAudioFn: transcribeAudio,
768
+ synthesizeSpeechFn: synthesizeSpeech,
769
+ voiceConfig: config.voice,
770
+ });
771
+ chatPoller.start();
772
+ console.log(
773
+ `[agent] chat poll loop started (${config.chat.pollIntervalMs ?? 5_000}ms interval)`,
774
+ );
775
+ }
776
+
777
+ // ─── Step 7: Slack Bolt Socket Mode (only when credentials present) ───────────
778
+ // Bolt's Socket Mode throws "Must provide an App-Level Token" if constructed
779
+ // without an appToken, so the agent runs Slack ONLY when both tokens are present.
780
+ // Absent creds → offline mode: skip Slack, keep health green, interact via the chat UI.
781
+ //
782
+ // SLK-1.1: startSlackIfPossible() (slack-startup.ts) is the single shared
783
+ // start path — attempted here AND again from inside syncConfig()'s success
784
+ // branch (Step 4, above — slackStartDeps is built earlier in this file so
785
+ // both call sites can share it). In the common case where credentials are
786
+ // already present at boot, Step 4's `await syncConfig()` (which runs before
787
+ // this point) already starts Slack, making this call a no-op; this call site
788
+ // exists so a config-bundle fetch that 404s or errors on the very first tick
789
+ // doesn't prevent Slack from starting when creds are already in the process
790
+ // env at boot. Every later syncConfig() tick is what retries the start for
791
+ // credentials that arrive after boot (the SLK-1.1 fix). Its own
792
+ // start-in-flight guard makes overlapping call sites racing safe; its own
793
+ // isAppStarted() check makes an already-successful start here a permanent
794
+ // no-op for every later attempt.
795
+
796
+ if (await startSlackIfPossible(slackStartDeps)) {
797
+ console.log("[agent] Slack app started — running");
798
+ } else if (!hasSlackCredentials(buildSlackAppConfig())) {
799
+ console.warn(
800
+ "[agent] Slack credentials absent (need SLACK_BOT_TOKEN + SLACK_APP_TOKEN) — " +
801
+ "skipping Slack startup. Offline mode: use the admin chat UI (/admin/chat) to interact.",
802
+ );
803
+ }
804
+
805
+ // ─── Step 8: Graceful shutdown ────────────────────────────────────────────────
806
+
807
+ let shuttingDown = false;
808
+
809
+ async function shutdown(signal: string) {
810
+ if (shuttingDown) return;
811
+ shuttingDown = true;
812
+ console.log(`[agent] received ${signal}, beginning graceful shutdown`);
813
+ markSlackDisconnected(slackClock);
814
+
815
+ // Stop cron tasks so no new Claude work fires during drain
816
+ for (const [id, task] of cronTasks) {
817
+ task.stop();
818
+ console.log(`[agent] cron unscheduled on shutdown: ${id}`);
819
+ }
820
+ cronTasks.clear();
821
+
822
+ // Close the Slack socket (bounded — don't let Bolt hang indefinitely).
823
+ // Skipped entirely when Slack never started (offline mode).
824
+ if (app) {
825
+ try {
826
+ await Promise.race([
827
+ app.stop(),
828
+ new Promise<void>((resolve) => setTimeout(resolve, 15_000)),
829
+ ]);
830
+ console.log("[agent] Slack app stopped");
831
+ } catch (err) {
832
+ console.error(
833
+ "[agent] app.stop() failed:",
834
+ err instanceof Error ? err.message : String(err),
835
+ );
836
+ }
837
+ }
838
+
839
+ console.log("[agent] shutdown complete");
840
+ process.exit(0);
841
+ }
842
+
843
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
844
+ process.on("SIGINT", () => void shutdown("SIGINT"));
845
+ }