@treeseed/core 0.6.38 → 0.6.39

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.
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { mkdir } from "node:fs/promises";
3
+ import { join } from "node:path";
2
4
  import { fileURLToPath } from "node:url";
3
5
  import { AgentKernel } from "../agents/kernel/agent-kernel.js";
6
+ import { createControlPlaneReporter } from "@treeseed/sdk";
4
7
  import { buildTaskContext, createQueueClient, createServiceSdk, resolveServiceRepoRoot, resolveWorkerConfig } from "./common.js";
5
8
  function parseTaskPayload(task) {
6
9
  const raw = typeof task?.payloadJson === "string" ? task.payloadJson : "{}";
@@ -10,11 +13,114 @@ function parseTaskPayload(task) {
10
13
  return {};
11
14
  }
12
15
  }
16
+ function asRecord(value) {
17
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
18
+ }
19
+ function readCapacityEnvelope(payload) {
20
+ const envelope = asRecord(payload.capacityEnvelope);
21
+ return Object.keys(envelope).length > 0 ? envelope : null;
22
+ }
23
+ function runnerRepositoryPath(volumeRoot, repositoryId, taskId) {
24
+ const repositoryRoot = join(volumeRoot, "repositories", repositoryId);
25
+ return {
26
+ repositoryRoot,
27
+ bareGit: join(repositoryRoot, "bare.git"),
28
+ worktree: join(repositoryRoot, "worktrees", taskId)
29
+ };
30
+ }
31
+ class WorkerPausedForApproval extends Error {
32
+ constructor(request) {
33
+ super(String(request.summary ?? request.title ?? "Task paused for approval."));
34
+ this.request = request;
35
+ }
36
+ request;
37
+ }
13
38
  async function executeQueuedTask(options) {
14
39
  const context = await buildTaskContext(options.sdk, options.taskId);
15
40
  const task = context.task;
16
41
  const payload = parseTaskPayload(task);
42
+ const capacityEnvelope = readCapacityEnvelope(payload);
43
+ const explicitApproval = asRecord(payload.approvalRequest);
44
+ if (Object.keys(explicitApproval).length > 0 || capacityEnvelope?.maxCredits === 0) {
45
+ throw new WorkerPausedForApproval({
46
+ kind: String(explicitApproval.kind ?? "capacity_boundary"),
47
+ title: String(explicitApproval.title ?? "Task paused for approval"),
48
+ summary: String(explicitApproval.summary ?? "The task reached a boundary outside its approved execution envelope."),
49
+ severity: explicitApproval.severity ?? "medium",
50
+ workDayId: task?.workDayId ?? task?.work_day_id ?? null,
51
+ taskId: options.taskId,
52
+ options: Array.isArray(explicitApproval.options) ? explicitApproval.options : [],
53
+ recommendation: asRecord(explicitApproval.recommendation),
54
+ policySnapshot: {
55
+ capacityEnvelope,
56
+ ...asRecord(explicitApproval.policySnapshot)
57
+ }
58
+ });
59
+ }
17
60
  const executionKind = typeof payload.executionKind === "string" ? payload.executionKind : null;
61
+ if (String(task?.type ?? task?.taskType ?? "") === "refresh_project_graph") {
62
+ const config = resolveWorkerConfig();
63
+ const projectId = typeof payload.projectId === "string" ? payload.projectId : config.projectId;
64
+ const repositoryId = typeof payload.repositoryId === "string" ? payload.repositoryId : projectId;
65
+ const paths = runnerRepositoryPath(config.volumeRoot, repositoryId, options.taskId);
66
+ await mkdir(paths.bareGit, { recursive: true });
67
+ await mkdir(paths.worktree, { recursive: true });
68
+ const graphRefresh = await options.sdk.refreshGraph();
69
+ const graphVersion = graphRefresh.snapshotRoot;
70
+ await options.sdk.create({
71
+ model: "graph_run",
72
+ data: {
73
+ id: `${options.taskId}:graph`,
74
+ workDayId: String(task?.workDayId ?? task?.work_day_id ?? ""),
75
+ corpusHash: graphVersion,
76
+ graphVersion,
77
+ statsJson: JSON.stringify(graphRefresh),
78
+ snapshotRef: graphVersion
79
+ },
80
+ actor: "worker"
81
+ });
82
+ if (typeof options.sdk.updateWorkDayGraph === "function") {
83
+ await options.sdk.updateWorkDayGraph({
84
+ id: String(task?.workDayId ?? task?.work_day_id ?? ""),
85
+ graphVersion,
86
+ summaryPatch: {
87
+ graphRefresh: {
88
+ state: "completed",
89
+ graphVersion,
90
+ snapshotRef: graphVersion,
91
+ runnerId: config.workerId
92
+ }
93
+ }
94
+ });
95
+ }
96
+ if (typeof options.sdk.recordRepositoryClaim === "function") {
97
+ await options.sdk.recordRepositoryClaim({
98
+ projectId,
99
+ repositoryId,
100
+ runnerId: config.workerId,
101
+ runnerServiceName: config.runnerServiceName,
102
+ volumeIdentity: config.volumeIdentity,
103
+ lastSeenCommit: typeof payload.commitSha === "string" ? payload.commitSha : null,
104
+ metadata: {
105
+ bareGit: paths.bareGit,
106
+ worktree: paths.worktree
107
+ }
108
+ });
109
+ }
110
+ return {
111
+ workerId: options.workerId,
112
+ queueAttempt: options.queueAttempt,
113
+ graphVersion,
114
+ snapshotRef: graphVersion,
115
+ repositoryId,
116
+ paths,
117
+ summary: {
118
+ status: "completed",
119
+ workerId: options.workerId,
120
+ summary: `Refreshed project graph ${graphVersion}`
121
+ }
122
+ };
123
+ }
18
124
  if (executionKind === "workflow_dispatch" || executionKind === "sdk_dispatch") {
19
125
  const namespace = typeof payload.namespace === "string" ? payload.namespace : "workflow";
20
126
  const operation = typeof payload.operation === "string" ? payload.operation : "";
@@ -57,7 +163,14 @@ async function executeQueuedTask(options) {
57
163
  status: agentResult.status,
58
164
  workerId: options.workerId,
59
165
  summary: agentResult.summary
60
- }
166
+ },
167
+ capacityUsage: capacityEnvelope?.providerId && capacityEnvelope?.laneId ? {
168
+ capacityProviderId: capacityEnvelope.providerId,
169
+ laneId: capacityEnvelope.laneId,
170
+ reservationId: capacityEnvelope.reservationIds?.[0] ?? null,
171
+ credits: Number(payload.estimatedCredits ?? capacityEnvelope.maxCredits ?? 1),
172
+ source: "worker"
173
+ } : null
61
174
  };
62
175
  }
63
176
  async function runWorkerCycle() {
@@ -65,6 +178,21 @@ async function runWorkerCycle() {
65
178
  const queue = createQueueClient();
66
179
  const config = resolveWorkerConfig();
67
180
  const kernel = new AgentKernel(sdk, resolveServiceRepoRoot());
181
+ if (typeof sdk.recordWorkerRunner === "function") {
182
+ await sdk.recordWorkerRunner({
183
+ projectId: config.projectId,
184
+ environment: config.environment,
185
+ runnerId: config.workerId,
186
+ runnerServiceName: config.runnerServiceName,
187
+ volumeIdentity: config.volumeIdentity,
188
+ state: "active",
189
+ maxLocalWorkers: config.maxLocalWorkers,
190
+ activeLocalWorkers: 0,
191
+ metadata: {
192
+ volumeRoot: config.volumeRoot
193
+ }
194
+ }).catch(() => null);
195
+ }
68
196
  if (!queue) {
69
197
  if (process.env.TREESEED_LOCAL_DEV_MODE?.trim()) {
70
198
  return { ok: true, processed: 0, idle: true, reason: "queue_unconfigured" };
@@ -78,8 +206,9 @@ async function runWorkerCycle() {
78
206
  if (pulled.messages.length === 0) {
79
207
  return { ok: true, processed: 0 };
80
208
  }
81
- let processed = 0;
82
- for (const message of pulled.messages) {
209
+ const maxLocalWorkers = Number.isFinite(Number(config.maxLocalWorkers)) ? Math.max(1, Number(config.maxLocalWorkers)) : 1;
210
+ const selectedMessages = pulled.messages.slice(0, maxLocalWorkers);
211
+ const results = await Promise.all(selectedMessages.map(async (message) => {
83
212
  try {
84
213
  await sdk.claimTask({
85
214
  id: message.body.taskId,
@@ -97,21 +226,78 @@ async function runWorkerCycle() {
97
226
  },
98
227
  actor: "worker"
99
228
  });
100
- const output = await executeQueuedTask({
101
- sdk,
102
- kernel,
103
- taskId: message.body.taskId,
104
- workerId: config.workerId,
105
- queueAttempt: message.attempts
106
- });
229
+ let output;
230
+ try {
231
+ output = await executeQueuedTask({
232
+ sdk,
233
+ kernel,
234
+ taskId: message.body.taskId,
235
+ workerId: config.workerId,
236
+ queueAttempt: message.attempts
237
+ });
238
+ } catch (error) {
239
+ if (error instanceof WorkerPausedForApproval) {
240
+ const reporter = createControlPlaneReporter();
241
+ const context = await buildTaskContext(sdk, message.body.taskId);
242
+ const task = context.task;
243
+ const projectId = String(process.env.TREESEED_PROJECT_ID ?? "");
244
+ await reporter.createApprovalRequest({
245
+ projectId,
246
+ teamId: String(error.request.teamId ?? process.env.TREESEED_TEAM_ID ?? ""),
247
+ workDayId: typeof error.request.workDayId === "string" ? error.request.workDayId : String(task?.workDayId ?? task?.work_day_id ?? ""),
248
+ taskId: message.body.taskId,
249
+ kind: String(error.request.kind ?? "capacity_boundary"),
250
+ severity: error.request.severity === "high" || error.request.severity === "low" ? error.request.severity : "medium",
251
+ requestedByType: "worker",
252
+ requestedById: config.workerId,
253
+ title: String(error.request.title ?? "Task paused for approval"),
254
+ summary: String(error.request.summary ?? error.message),
255
+ options: Array.isArray(error.request.options) ? error.request.options : [],
256
+ recommendation: asRecord(error.request.recommendation),
257
+ policySnapshot: asRecord(error.request.policySnapshot)
258
+ }).catch(() => null);
259
+ await sdk.recordTaskProgress({
260
+ id: message.body.taskId,
261
+ workerId: config.workerId,
262
+ state: "paused_for_approval",
263
+ appendEvent: {
264
+ kind: "paused_for_approval",
265
+ data: error.request
266
+ },
267
+ actor: "worker"
268
+ });
269
+ await queue.ack([message.leaseId]);
270
+ return 1;
271
+ }
272
+ throw error;
273
+ }
107
274
  await sdk.completeTask({
108
275
  id: message.body.taskId,
109
276
  output,
110
277
  summary: output.summary,
111
278
  actor: "worker"
112
279
  });
280
+ if (output.capacityUsage?.capacityProviderId && output.capacityUsage?.laneId) {
281
+ const reporter = createControlPlaneReporter();
282
+ await reporter.reportCapacityUsage({
283
+ capacityProviderId: output.capacityUsage.capacityProviderId,
284
+ laneId: output.capacityUsage.laneId,
285
+ reservationId: output.capacityUsage.reservationId,
286
+ teamId: String(process.env.TREESEED_TEAM_ID ?? ""),
287
+ projectId: String(process.env.TREESEED_PROJECT_ID ?? ""),
288
+ workDayId: message.body.workDayId,
289
+ taskId: message.body.taskId,
290
+ phase: "consume",
291
+ credits: output.capacityUsage.credits,
292
+ source: "worker",
293
+ metadata: {
294
+ workerId: config.workerId,
295
+ queueAttempt: message.attempts
296
+ }
297
+ }).catch(() => null);
298
+ }
113
299
  await queue.ack([message.leaseId]);
114
- processed += 1;
300
+ return 1;
115
301
  } catch (error) {
116
302
  const retryDelaySeconds = Math.min(300, Math.max(15, message.attempts * 30));
117
303
  await sdk.failTask({
@@ -122,15 +308,62 @@ async function runWorkerCycle() {
122
308
  actor: "worker"
123
309
  }).catch(() => null);
124
310
  await queue.retry([{ leaseId: message.leaseId, delaySeconds: retryDelaySeconds }]);
311
+ return 0;
125
312
  }
313
+ }));
314
+ return { ok: true, processed: results.reduce((sum, value) => sum + value, 0) };
315
+ }
316
+ function shouldExitWorkerLoopAfterIdle(options) {
317
+ const idleExitMs = Number(options.idleExitMs ?? 0);
318
+ if (!Number.isFinite(idleExitMs) || idleExitMs <= 0) {
319
+ return false;
126
320
  }
127
- return { ok: true, processed };
321
+ if (options.processed > 0 || options.idleSince === null) {
322
+ return false;
323
+ }
324
+ return options.now - options.idleSince >= idleExitMs;
325
+ }
326
+ async function recordWorkerLoopExitState(config) {
327
+ const sdk = createServiceSdk();
328
+ if (typeof sdk.recordWorkerRunner !== "function") {
329
+ return;
330
+ }
331
+ await sdk.recordWorkerRunner({
332
+ projectId: config.projectId,
333
+ environment: config.environment,
334
+ runnerId: config.workerId,
335
+ runnerServiceName: config.runnerServiceName,
336
+ volumeIdentity: config.volumeIdentity,
337
+ state: "sleeping",
338
+ maxLocalWorkers: config.maxLocalWorkers,
339
+ activeLocalWorkers: 0,
340
+ metadata: {
341
+ volumeRoot: config.volumeRoot,
342
+ reason: "idle_exit"
343
+ }
344
+ }).catch(() => null);
128
345
  }
129
346
  async function startWorkerLoop() {
130
347
  const config = resolveWorkerConfig();
348
+ let idleSince = null;
131
349
  for (; ; ) {
132
350
  try {
133
- await runWorkerCycle();
351
+ const result = await runWorkerCycle();
352
+ const processed = Number(result.processed ?? 0);
353
+ if (processed > 0) {
354
+ idleSince = null;
355
+ } else {
356
+ idleSince ??= Date.now();
357
+ if (shouldExitWorkerLoopAfterIdle({
358
+ idleExitMs: config.idleExitMs,
359
+ idleSince,
360
+ now: Date.now(),
361
+ processed
362
+ })) {
363
+ await recordWorkerLoopExitState(config);
364
+ return;
365
+ }
366
+ }
134
367
  } catch (error) {
135
368
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}
136
369
  `);
@@ -145,5 +378,6 @@ if (entryFile === currentFile) {
145
378
  }
146
379
  export {
147
380
  runWorkerCycle,
381
+ shouldExitWorkerLoopAfterIdle,
148
382
  startWorkerLoop
149
383
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treeseed/core",
3
- "version": "0.6.38",
3
+ "version": "0.6.39",
4
4
  "description": "Treeseed integrated platform starter for Astro/Starlight web runtimes and Hono API runtimes.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -42,12 +42,14 @@
42
42
  "dev": "node ./scripts/run-ts.mjs ./scripts/dev-platform.ts",
43
43
  "dev:web": "node ./scripts/run-ts.mjs ./scripts/dev-platform.ts --surface web",
44
44
  "dev:api": "node ./scripts/run-ts.mjs ./scripts/dev-platform.ts --surface api",
45
- "dev:manager": "node ./scripts/run-ts.mjs ./src/services/manager.ts",
46
- "dev:worker": "node ./scripts/run-ts.mjs ./src/services/worker.ts",
45
+ "dev:workday-manager": "node ./scripts/run-ts.mjs ./src/services/workday-manager.ts",
46
+ "dev:worker-runner": "node ./scripts/run-ts.mjs ./src/services/worker.ts",
47
+ "dev:manager": "npm run dev:workday-manager",
48
+ "dev:worker": "npm run dev:worker-runner",
47
49
  "dev:agents": "node ./scripts/run-ts.mjs ./src/services/agents.ts",
48
50
  "dev:remote-runner": "node ./scripts/run-ts.mjs ./src/services/remote-runner.ts",
49
- "dev:workday-start": "node ./scripts/run-ts.mjs ./src/services/workday-start.ts",
50
- "dev:workday-report": "node ./scripts/run-ts.mjs ./src/services/workday-report.ts",
51
+ "dev:workday-start": "npm run dev:workday-manager",
52
+ "dev:workday-report": "npm run dev:workday-manager",
51
53
  "dev:watch": "node ./scripts/run-ts.mjs ./scripts/dev-platform.ts --watch",
52
54
  "starlight:patch": "node ./scripts/run-ts.mjs ./scripts/patch-starlight-content-path.ts",
53
55
  "precheck": "npm run starlight:patch",
@@ -76,7 +78,7 @@
76
78
  "@astrojs/sitemap": "3.7.0",
77
79
  "@astrojs/starlight": "0.37.6",
78
80
  "@tailwindcss/vite": "^4.1.4",
79
- "@treeseed/sdk": "0.6.39",
81
+ "@treeseed/sdk": "0.6.40",
80
82
  "astro": "^5.6.1",
81
83
  "esbuild": "^0.28.0",
82
84
  "hono": "^4.8.2",
@@ -187,6 +189,10 @@
187
189
  "types": "./dist/services/remote-runner.d.ts",
188
190
  "default": "./dist/services/remote-runner.js"
189
191
  },
192
+ "./services/workday-manager": {
193
+ "types": "./dist/services/workday-manager.d.ts",
194
+ "default": "./dist/services/workday-manager.js"
195
+ },
190
196
  "./services/workday-start": {
191
197
  "types": "./dist/services/workday-start.d.ts",
192
198
  "default": "./dist/services/workday-start.js"