@app-vitals/agent-runtime 0.0.1 → 0.1.1
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/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +50 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- 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
|
+
}
|