@grackle-ai/server 0.25.0 → 0.27.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 (73) hide show
  1. package/dist/adapter-manager.d.ts.map +1 -1
  2. package/dist/adapter-manager.js +1 -0
  3. package/dist/adapter-manager.js.map +1 -1
  4. package/dist/adapters/codespace.js +1 -1
  5. package/dist/adapters/codespace.js.map +1 -1
  6. package/dist/adapters/docker.js +1 -1
  7. package/dist/adapters/docker.js.map +1 -1
  8. package/dist/adapters/local.js +1 -1
  9. package/dist/adapters/local.js.map +1 -1
  10. package/dist/adapters/remote-adapter-utils.d.ts.map +1 -1
  11. package/dist/adapters/remote-adapter-utils.js +5 -4
  12. package/dist/adapters/remote-adapter-utils.js.map +1 -1
  13. package/dist/adapters/ssh.js +1 -1
  14. package/dist/adapters/ssh.js.map +1 -1
  15. package/dist/compute-task-status.d.ts +31 -0
  16. package/dist/compute-task-status.d.ts.map +1 -0
  17. package/dist/compute-task-status.js +88 -0
  18. package/dist/compute-task-status.js.map +1 -0
  19. package/dist/db.d.ts.map +1 -1
  20. package/dist/db.js +112 -19
  21. package/dist/db.js.map +1 -1
  22. package/dist/event-processor.d.ts +0 -1
  23. package/dist/event-processor.d.ts.map +1 -1
  24. package/dist/event-processor.js +28 -32
  25. package/dist/event-processor.js.map +1 -1
  26. package/dist/github-import.d.ts +1 -1
  27. package/dist/github-import.d.ts.map +1 -1
  28. package/dist/github-import.js +7 -7
  29. package/dist/github-import.js.map +1 -1
  30. package/dist/grpc-service.d.ts +0 -6
  31. package/dist/grpc-service.d.ts.map +1 -1
  32. package/dist/grpc-service.js +142 -117
  33. package/dist/grpc-service.js.map +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +23 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/processor-registry.d.ts +1 -2
  38. package/dist/processor-registry.d.ts.map +1 -1
  39. package/dist/processor-registry.js +1 -4
  40. package/dist/processor-registry.js.map +1 -1
  41. package/dist/project-store.d.ts +3 -1
  42. package/dist/project-store.d.ts.map +1 -1
  43. package/dist/project-store.js +5 -1
  44. package/dist/project-store.js.map +1 -1
  45. package/dist/schema.d.ts +38 -95
  46. package/dist/schema.d.ts.map +1 -1
  47. package/dist/schema.js +3 -6
  48. package/dist/schema.js.map +1 -1
  49. package/dist/session-store.d.ts +7 -1
  50. package/dist/session-store.d.ts.map +1 -1
  51. package/dist/session-store.js +42 -4
  52. package/dist/session-store.js.map +1 -1
  53. package/dist/task-store.d.ts +9 -10
  54. package/dist/task-store.d.ts.map +1 -1
  55. package/dist/task-store.js +14 -36
  56. package/dist/task-store.js.map +1 -1
  57. package/dist/transcript.js.map +1 -1
  58. package/dist/utils/format-gh-error.d.ts +6 -0
  59. package/dist/utils/format-gh-error.d.ts.map +1 -0
  60. package/dist/utils/format-gh-error.js +30 -0
  61. package/dist/utils/format-gh-error.js.map +1 -0
  62. package/dist/utils/system-context.d.ts +1 -1
  63. package/dist/utils/system-context.d.ts.map +1 -1
  64. package/dist/utils/system-context.js +2 -2
  65. package/dist/utils/system-context.js.map +1 -1
  66. package/dist/ws-bridge.d.ts.map +1 -1
  67. package/dist/ws-bridge.js +175 -171
  68. package/dist/ws-bridge.js.map +1 -1
  69. package/dist/ws-broadcast.d.ts +5 -0
  70. package/dist/ws-broadcast.d.ts.map +1 -1
  71. package/dist/ws-broadcast.js +20 -0
  72. package/dist/ws-broadcast.js.map +1 -1
  73. package/package.json +4 -4
@@ -12,40 +12,18 @@ import * as projectStore from "./project-store.js";
12
12
  import * as taskStore from "./task-store.js";
13
13
  import * as findingStore from "./finding-store.js";
14
14
  import * as personaStore from "./persona-store.js";
15
- import { broadcast } from "./ws-broadcast.js";
15
+ import { broadcast, broadcastEnvironments } from "./ws-broadcast.js";
16
16
  import { processEventStream } from "./event-processor.js";
17
17
  import * as processorRegistry from "./processor-registry.js";
18
18
  import { join } from "node:path";
19
- import { LOGS_DIR, DEFAULT_RUNTIME, DEFAULT_MODEL, MAX_TASK_DEPTH, taskStatusToEnum, taskStatusToString, projectStatusToEnum, } from "@grackle-ai/common";
19
+ import { LOGS_DIR, DEFAULT_RUNTIME, DEFAULT_MODEL, MAX_TASK_DEPTH, SESSION_STATUS, TASK_STATUS, taskStatusToEnum, taskStatusToString, projectStatusToEnum, } from "@grackle-ai/common";
20
20
  import { grackleHome } from "./paths.js";
21
21
  import { safeParseJsonArray } from "./json-helpers.js";
22
+ import { computeTaskStatus } from "./compute-task-status.js";
22
23
  import { logger } from "./logger.js";
23
24
  import { slugify } from "./utils/slugify.js";
24
25
  import { buildTaskSystemContext } from "./utils/system-context.js";
25
26
  import { importGitHubIssues as executeGitHubImport } from "./github-import.js";
26
- /**
27
- * Build a completion callback that transitions a task to review/failed
28
- * when its associated session ends. Shared across startTask, resumeAgent,
29
- * updateTask (late-bind), and WS bridge paths.
30
- */
31
- export function makeTaskCompletionCallback(taskId, sessionId, projectId) {
32
- return () => {
33
- const t = taskStore.getTask(taskId);
34
- if (t && (t.status === "in_progress" || t.status === "waiting_input")) {
35
- const sess = sessionStore.getSession(sessionId);
36
- if (sess?.status === "completed") {
37
- taskStore.markTaskCompleted(taskId, "review");
38
- }
39
- else if (sess?.status === "failed") {
40
- taskStore.markTaskCompleted(taskId, "failed");
41
- }
42
- broadcast({
43
- type: "task_updated",
44
- payload: { taskId, projectId },
45
- });
46
- }
47
- };
48
- }
49
27
  function envRowToProto(row) {
50
28
  return create(grackle.EnvironmentSchema, {
51
29
  id: row.id,
@@ -75,7 +53,8 @@ function sessionRowToProto(row) {
75
53
  suspendedAt: row.suspendedAt ?? "",
76
54
  endedAt: row.endedAt ?? "",
77
55
  error: row.error ?? "",
78
- taskId: row.taskId ?? "",
56
+ taskId: row.taskId,
57
+ personaId: row.personaId,
79
58
  });
80
59
  }
81
60
  function projectRowToProto(row) {
@@ -89,23 +68,21 @@ function projectRowToProto(row) {
89
68
  createdAt: row.createdAt,
90
69
  updatedAt: row.updatedAt,
91
70
  useWorktrees: row.useWorktrees,
71
+ worktreeBasePath: row.worktreeBasePath,
92
72
  });
93
73
  }
94
- function taskRowToProto(row, childIds) {
74
+ function taskRowToProto(row, childIds, computedStatus, latestSessionId) {
95
75
  return create(grackle.TaskSchema, {
96
76
  id: row.id,
97
77
  projectId: row.projectId,
98
78
  title: row.title,
99
79
  description: row.description,
100
- status: taskStatusToEnum(row.status),
80
+ status: taskStatusToEnum(computedStatus ?? row.status),
101
81
  branch: row.branch,
102
- environmentId: row.environmentId,
103
- sessionId: row.sessionId,
82
+ latestSessionId: latestSessionId ?? "",
104
83
  dependsOn: safeParseJsonArray(row.dependsOn),
105
- assignedAt: row.assignedAt ?? "",
106
84
  startedAt: row.startedAt ?? "",
107
85
  completedAt: row.completedAt ?? "",
108
- reviewNotes: row.reviewNotes,
109
86
  createdAt: row.createdAt,
110
87
  updatedAt: row.updatedAt,
111
88
  sortOrder: row.sortOrder,
@@ -113,7 +90,6 @@ function taskRowToProto(row, childIds) {
113
90
  depth: row.depth,
114
91
  childTaskIds: childIds ?? taskStore.getChildren(row.id).map((c) => c.id),
115
92
  canDecompose: row.canDecompose,
116
- personaId: row.personaId,
117
93
  });
118
94
  }
119
95
  function findingRowToProto(row) {
@@ -155,8 +131,10 @@ function personaRowToProto(row) {
155
131
  model: row.model,
156
132
  maxTurns: row.maxTurns,
157
133
  mcpServers: mcpServers
158
- .filter((s) => typeof s === "object" &&
159
- s !== null &&
134
+ .filter((s) =>
135
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typeof null === "object", JSON.parse can return null
136
+ s !== null &&
137
+ typeof s === "object" &&
160
138
  typeof s.name === "string" &&
161
139
  typeof s.command === "string")
162
140
  .map((s) => create(grackle.McpServerConfigSchema, {
@@ -253,8 +231,9 @@ export function registerGrackleRoutes(router) {
253
231
  return;
254
232
  }
255
233
  envRegistry.updateEnvironmentStatus(req.id, "connecting");
234
+ broadcastEnvironments();
256
235
  const config = JSON.parse(env.adapterConfig);
257
- const powerlineToken = env.powerlineToken || "";
236
+ const powerlineToken = env.powerlineToken;
258
237
  for await (const event of reconnectOrProvision(req.id, adapter, config, powerlineToken, !!env.bootstrapped)) {
259
238
  yield create(grackle.ProvisionEventSchema, {
260
239
  stage: event.stage,
@@ -269,6 +248,7 @@ export function registerGrackleRoutes(router) {
269
248
  await tokenBroker.pushToEnv(req.id);
270
249
  envRegistry.updateEnvironmentStatus(req.id, "connected");
271
250
  envRegistry.markBootstrapped(req.id);
251
+ broadcastEnvironments();
272
252
  yield create(grackle.ProvisionEventSchema, {
273
253
  stage: "ready",
274
254
  message: "Environment connected",
@@ -277,9 +257,10 @@ export function registerGrackleRoutes(router) {
277
257
  }
278
258
  catch (err) {
279
259
  envRegistry.updateEnvironmentStatus(req.id, "error");
260
+ broadcastEnvironments();
280
261
  yield create(grackle.ProvisionEventSchema, {
281
262
  stage: "error",
282
- message: `Connection failed: ${err}`,
263
+ message: `Connection failed: ${err instanceof Error ? err.message : String(err)}`,
283
264
  progress: 0,
284
265
  });
285
266
  }
@@ -295,6 +276,7 @@ export function registerGrackleRoutes(router) {
295
276
  }
296
277
  adapterManager.removeConnection(req.id);
297
278
  envRegistry.updateEnvironmentStatus(req.id, "disconnected");
279
+ broadcastEnvironments();
298
280
  return create(grackle.EmptySchema, {});
299
281
  },
300
282
  async destroyEnvironment(req) {
@@ -308,6 +290,7 @@ export function registerGrackleRoutes(router) {
308
290
  }
309
291
  adapterManager.removeConnection(req.id);
310
292
  envRegistry.updateEnvironmentStatus(req.id, "disconnected");
293
+ broadcastEnvironments();
311
294
  return create(grackle.EmptySchema, {});
312
295
  },
313
296
  async spawnAgent(req) {
@@ -346,8 +329,10 @@ export function registerGrackleRoutes(router) {
346
329
  prompt: req.prompt,
347
330
  model,
348
331
  maxTurns: persona?.maxTurns || 0,
349
- branch: req.branch || "",
350
- worktreeBasePath: req.branch ? "/workspace" : "",
332
+ branch: req.branch,
333
+ worktreeBasePath: req.branch
334
+ ? (req.worktreeBasePath.trim() || process.env.GRACKLE_WORKTREE_BASE || "/workspace")
335
+ : "",
351
336
  systemContext,
352
337
  mcpServersJson,
353
338
  });
@@ -376,13 +361,11 @@ export function registerGrackleRoutes(router) {
376
361
  // Auto-bind task context from DB if session was previously associated with a task
377
362
  let resumeProjectId;
378
363
  let resumeTaskId;
379
- let resumeOnComplete;
380
364
  if (session.taskId) {
381
365
  const task = taskStore.getTask(session.taskId);
382
366
  if (task) {
383
367
  resumeProjectId = task.projectId;
384
368
  resumeTaskId = task.id;
385
- resumeOnComplete = makeTaskCompletionCallback(task.id, session.id, task.projectId);
386
369
  }
387
370
  }
388
371
  processEventStream(conn.client.resume(powerlineReq), {
@@ -390,7 +373,6 @@ export function registerGrackleRoutes(router) {
390
373
  logPath,
391
374
  projectId: resumeProjectId,
392
375
  taskId: resumeTaskId,
393
- onComplete: resumeOnComplete,
394
376
  });
395
377
  const row = sessionStore.getSession(session.id);
396
378
  return sessionRowToProto(row);
@@ -400,8 +382,8 @@ export function registerGrackleRoutes(router) {
400
382
  if (!session) {
401
383
  throw new Error(`Session not found: ${req.sessionId}`);
402
384
  }
403
- if (session.status !== "waiting_input") {
404
- throw new Error(`Session ${req.sessionId} is not waiting for input (status: ${session.status})`);
385
+ if (session.status !== SESSION_STATUS.IDLE) {
386
+ throw new Error(`Session ${req.sessionId} is not idle (status: ${session.status})`);
405
387
  }
406
388
  const conn = adapterManager.getConnection(session.environmentId);
407
389
  if (!conn) {
@@ -420,16 +402,28 @@ export function registerGrackleRoutes(router) {
420
402
  }
421
403
  const conn = adapterManager.getConnection(session.environmentId);
422
404
  if (conn) {
423
- await conn.client.kill(create(powerline.SessionIdSchema, { id: req.id }));
405
+ try {
406
+ await conn.client.kill(create(powerline.SessionIdSchema, { id: req.id }));
407
+ }
408
+ catch (err) {
409
+ logger.warn({ sessionId: req.id, err }, "PowerLine kill failed — marking session interrupted anyway");
410
+ }
424
411
  }
425
- sessionStore.updateSession(req.id, "killed");
412
+ sessionStore.updateSession(req.id, SESSION_STATUS.INTERRUPTED);
426
413
  streamHub.publish(create(grackle.SessionEventSchema, {
427
414
  sessionId: req.id,
428
415
  type: grackle.EventType.STATUS,
429
416
  timestamp: new Date().toISOString(),
430
- content: "killed",
417
+ content: SESSION_STATUS.INTERRUPTED,
431
418
  raw: "",
432
419
  }));
420
+ // Broadcast task_updated so frontend re-fetches computed status
421
+ if (session.taskId) {
422
+ const task = taskStore.getTask(session.taskId);
423
+ if (task) {
424
+ broadcast({ type: "task_updated", payload: { taskId: task.id, projectId: task.projectId } });
425
+ }
426
+ }
433
427
  return create(grackle.EmptySchema, {});
434
428
  },
435
429
  async listSessions(req) {
@@ -502,7 +496,7 @@ export function registerGrackleRoutes(router) {
502
496
  }
503
497
  // useWorktrees defaults to true when not specified
504
498
  const useWorktrees = req.useWorktrees ?? true;
505
- projectStore.createProject(id, req.name, req.description, req.repoUrl, req.defaultEnvironmentId, useWorktrees);
499
+ projectStore.createProject(id, req.name, req.description, req.repoUrl, req.defaultEnvironmentId, useWorktrees, req.worktreeBasePath ?? "");
506
500
  broadcast({ type: "project_created", payload: { projectId: id } });
507
501
  const row = projectStore.getProject(id);
508
502
  return projectRowToProto(row);
@@ -523,7 +517,7 @@ export function registerGrackleRoutes(router) {
523
517
  if (!existing) {
524
518
  throw new Error(`Project not found: ${req.id}`);
525
519
  }
526
- if (req.name !== undefined && req.name.trim() === "") {
520
+ if (req.name?.trim() === "") {
527
521
  throw new Error("Project name cannot be empty");
528
522
  }
529
523
  if (req.repoUrl !== undefined && req.repoUrl !== "" && !/^https?:\/\//i.test(req.repoUrl)) {
@@ -535,6 +529,7 @@ export function registerGrackleRoutes(router) {
535
529
  repoUrl: req.repoUrl,
536
530
  defaultEnvironmentId: req.defaultEnvironmentId,
537
531
  useWorktrees: req.useWorktrees ?? undefined,
532
+ worktreeBasePath: req.worktreeBasePath,
538
533
  });
539
534
  if (!row) {
540
535
  throw new Error(`Project not found after update: ${req.id}`);
@@ -546,8 +541,21 @@ export function registerGrackleRoutes(router) {
546
541
  async listTasks(req) {
547
542
  const rows = taskStore.listTasks(req.id);
548
543
  const childIdsMap = taskStore.buildChildIdsMap(rows);
544
+ // Batch-fetch sessions for all tasks and group by taskId
545
+ const taskIds = rows.map((r) => r.id);
546
+ const allSessions = sessionStore.listSessionsByTaskIds(taskIds);
547
+ const sessionsByTask = new Map();
548
+ for (const s of allSessions) {
549
+ const arr = sessionsByTask.get(s.taskId) ?? [];
550
+ arr.push(s);
551
+ sessionsByTask.set(s.taskId, arr);
552
+ }
549
553
  return create(grackle.TaskListSchema, {
550
- tasks: rows.map((r) => taskRowToProto(r, childIdsMap.get(r.id) ?? [])),
554
+ tasks: rows.map((r) => {
555
+ const taskSessions = sessionsByTask.get(r.id) ?? [];
556
+ const { status, latestSessionId } = computeTaskStatus(r.status, taskSessions);
557
+ return taskRowToProto(r, childIdsMap.get(r.id) ?? [], status, latestSessionId);
558
+ }),
551
559
  });
552
560
  },
553
561
  async createTask(req) {
@@ -567,18 +575,7 @@ export function registerGrackleRoutes(router) {
567
575
  }
568
576
  }
569
577
  const id = uuid().slice(0, 8);
570
- // Resolve environment: explicit > parent task's env > project default
571
- let environmentId = req.environmentId;
572
- if (!environmentId && req.parentTaskId) {
573
- const parent = taskStore.getTask(req.parentTaskId);
574
- if (parent?.environmentId) {
575
- environmentId = parent.environmentId;
576
- }
577
- }
578
- if (!environmentId) {
579
- environmentId = project.defaultEnvironmentId;
580
- }
581
- taskStore.createTask(id, req.projectId, req.title, req.description, environmentId, [...req.dependsOn], slugify(project.name), req.parentTaskId, req.canDecompose, req.personaId);
578
+ taskStore.createTask(id, req.projectId, req.title, req.description, [...req.dependsOn], slugify(project.name), req.parentTaskId, req.canDecompose);
582
579
  const row = taskStore.getTask(id);
583
580
  broadcast({
584
581
  type: "task_created",
@@ -590,7 +587,9 @@ export function registerGrackleRoutes(router) {
590
587
  const row = taskStore.getTask(req.id);
591
588
  if (!row)
592
589
  throw new Error(`Task not found: ${req.id}`);
593
- return taskRowToProto(row);
590
+ const taskSessions = sessionStore.listSessionsForTask(req.id);
591
+ const { status, latestSessionId } = computeTaskStatus(row.status, taskSessions);
592
+ return taskRowToProto(row, undefined, status, latestSessionId);
594
593
  },
595
594
  async updateTask(req) {
596
595
  const existing = taskStore.getTask(req.id);
@@ -604,16 +603,16 @@ export function registerGrackleRoutes(router) {
604
603
  }
605
604
  reqStatus = converted;
606
605
  }
607
- taskStore.updateTask(req.id, req.title !== "" ? req.title : existing.title, req.description !== "" ? req.description : existing.description, reqStatus, req.environmentId !== "" ? req.environmentId : existing.environmentId, req.dependsOn.length > 0
606
+ taskStore.updateTask(req.id, req.title !== "" ? req.title : existing.title, req.description !== "" ? req.description : existing.description, reqStatus, req.dependsOn.length > 0
608
607
  ? [...req.dependsOn]
609
- : safeParseJsonArray(existing.dependsOn), req.reviewNotes !== "" ? req.reviewNotes : existing.reviewNotes, existing.personaId);
608
+ : safeParseJsonArray(existing.dependsOn));
610
609
  // Late-bind: associate an existing session with this task
611
610
  if (req.sessionId !== "") {
612
611
  const session = sessionStore.getSession(req.sessionId);
613
612
  if (!session) {
614
613
  throw new ConnectError(`Session not found: ${req.sessionId}`, Code.NotFound);
615
614
  }
616
- const terminalStatuses = ["completed", "failed", "killed"];
615
+ const terminalStatuses = [SESSION_STATUS.COMPLETED, SESSION_STATUS.FAILED, SESSION_STATUS.INTERRUPTED];
617
616
  if (terminalStatuses.includes(session.status)) {
618
617
  throw new ConnectError(`Cannot bind terminal session ${req.sessionId} (status: ${session.status})`, Code.FailedPrecondition);
619
618
  }
@@ -622,24 +621,27 @@ export function registerGrackleRoutes(router) {
622
621
  throw new ConnectError(`No active event processor for session ${req.sessionId}`, Code.FailedPrecondition);
623
622
  }
624
623
  sessionStore.setSessionTask(req.sessionId, req.id);
625
- taskStore.setTaskSession(req.id, req.sessionId);
626
- taskStore.markTaskStarted(req.id);
627
- const onComplete = makeTaskCompletionCallback(req.id, req.sessionId, existing.projectId);
628
- processorRegistry.lateBind(req.sessionId, req.id, existing.projectId, onComplete);
624
+ processorRegistry.lateBind(req.sessionId, req.id, existing.projectId);
629
625
  broadcast({
630
626
  type: "task_started",
631
627
  payload: { taskId: req.id, sessionId: req.sessionId, projectId: existing.projectId },
632
628
  });
633
629
  }
634
630
  const row = taskStore.getTask(req.id);
635
- return taskRowToProto(row);
631
+ const taskSessions = sessionStore.listSessionsForTask(req.id);
632
+ const { status, latestSessionId } = computeTaskStatus(row.status, taskSessions);
633
+ return taskRowToProto(row, undefined, status, latestSessionId);
636
634
  },
637
635
  async startTask(req) {
638
636
  const task = taskStore.getTask(req.taskId);
639
637
  if (!task)
640
638
  throw new Error(`Task not found: ${req.taskId}`);
641
- if (!["pending", "assigned", "failed"].includes(task.status)) {
642
- throw new Error(`Task ${req.taskId} cannot be started (status: ${task.status})`);
639
+ {
640
+ const taskSessions = sessionStore.listSessionsForTask(req.taskId);
641
+ const { status: effectiveStatus } = computeTaskStatus(task.status, taskSessions);
642
+ if (![TASK_STATUS.NOT_STARTED, TASK_STATUS.FAILED].includes(effectiveStatus)) {
643
+ throw new Error(`Task ${req.taskId} cannot be started (status: ${effectiveStatus})`);
644
+ }
643
645
  }
644
646
  if (!taskStore.areDependenciesMet(req.taskId)) {
645
647
  throw new Error(`Task ${req.taskId} has unmet dependencies`);
@@ -647,14 +649,15 @@ export function registerGrackleRoutes(router) {
647
649
  const project = projectStore.getProject(task.projectId);
648
650
  if (!project)
649
651
  throw new Error(`Project not found: ${task.projectId}`);
650
- const environmentId = task.environmentId || project.defaultEnvironmentId;
651
- if (!environmentId)
652
+ const environmentId = req.environmentId || project.defaultEnvironmentId;
653
+ if (!environmentId) {
652
654
  throw new Error("No environment specified for task or project");
655
+ }
653
656
  const conn = adapterManager.getConnection(environmentId);
654
657
  if (!conn)
655
658
  throw new Error(`Environment ${environmentId} not connected`);
656
- // Resolve persona (StartTaskRequest override > task's stored persona)
657
- const personaId = req.personaId || task.personaId;
659
+ // Resolve persona from StartTaskRequest
660
+ const personaId = req.personaId || "";
658
661
  const persona = personaId
659
662
  ? personaStore.getPersona(personaId)
660
663
  : undefined;
@@ -673,13 +676,11 @@ export function registerGrackleRoutes(router) {
673
676
  DEFAULT_MODEL;
674
677
  const maxTurns = persona?.maxTurns || 0;
675
678
  const logPath = join(grackleHome, LOGS_DIR, sessionId);
676
- let systemContext = buildTaskSystemContext(task.title, task.description, task.reviewNotes, task.canDecompose);
679
+ let systemContext = buildTaskSystemContext(task.title, task.description, req.notes || "", task.canDecompose);
677
680
  if (persona) {
678
681
  systemContext = persona.systemPrompt + "\n\n" + systemContext;
679
682
  }
680
- sessionStore.createSession(sessionId, environmentId, runtime, task.title, model, logPath, task.id);
681
- taskStore.setTaskSession(task.id, sessionId);
682
- taskStore.markTaskStarted(task.id);
683
+ sessionStore.createSession(sessionId, environmentId, runtime, task.title, model, logPath, task.id, personaId);
683
684
  broadcast({
684
685
  type: "task_started",
685
686
  payload: { taskId: task.id, sessionId, projectId: task.projectId },
@@ -701,7 +702,9 @@ export function registerGrackleRoutes(router) {
701
702
  model,
702
703
  maxTurns,
703
704
  branch: task.branch,
704
- worktreeBasePath: task.branch && useWorktrees ? "/workspace" : "",
705
+ worktreeBasePath: task.branch && useWorktrees
706
+ ? (project.worktreeBasePath || process.env.GRACKLE_WORKTREE_BASE || "/workspace")
707
+ : "",
705
708
  systemContext,
706
709
  projectId: task.projectId,
707
710
  taskId: task.id,
@@ -712,16 +715,15 @@ export function registerGrackleRoutes(router) {
712
715
  logPath,
713
716
  projectId: task.projectId,
714
717
  taskId: task.id,
715
- onComplete: makeTaskCompletionCallback(task.id, sessionId, task.projectId),
716
718
  });
717
719
  const row = sessionStore.getSession(sessionId);
718
720
  return sessionRowToProto(row);
719
721
  },
720
- async approveTask(req) {
722
+ async completeTask(req) {
721
723
  const task = taskStore.getTask(req.id);
722
724
  if (!task)
723
725
  throw new Error(`Task not found: ${req.id}`);
724
- taskStore.markTaskCompleted(task.id, "done");
726
+ taskStore.markTaskComplete(task.id, TASK_STATUS.COMPLETE);
725
727
  // Check for newly unblocked tasks
726
728
  const unblocked = taskStore.checkAndUnblock(task.projectId);
727
729
  for (const t of unblocked) {
@@ -738,23 +740,50 @@ export function registerGrackleRoutes(router) {
738
740
  }));
739
741
  }
740
742
  broadcast({
741
- type: "task_approved",
743
+ type: "task_completed",
742
744
  payload: { taskId: task.id, projectId: task.projectId },
743
745
  });
744
746
  const row = taskStore.getTask(task.id);
745
- return taskRowToProto(row);
747
+ const taskSessions = sessionStore.listSessionsForTask(task.id);
748
+ const { status, latestSessionId } = computeTaskStatus(row.status, taskSessions);
749
+ return taskRowToProto(row, undefined, status, latestSessionId);
746
750
  },
747
- async rejectTask(req) {
751
+ async resumeTask(req) {
748
752
  const task = taskStore.getTask(req.id);
749
753
  if (!task)
750
754
  throw new Error(`Task not found: ${req.id}`);
751
- taskStore.updateTask(task.id, task.title, task.description, "assigned", task.environmentId, safeParseJsonArray(task.dependsOn), req.reviewNotes || "", task.personaId);
755
+ const latestSession = sessionStore.getLatestSessionForTask(req.id);
756
+ if (!latestSession) {
757
+ throw new Error(`Task ${req.id} has no sessions to resume`);
758
+ }
759
+ if (![SESSION_STATUS.INTERRUPTED, SESSION_STATUS.COMPLETED].includes(latestSession.status)) {
760
+ throw new Error(`Latest session ${latestSession.id} is not resumable (status: ${latestSession.status})`);
761
+ }
762
+ if (!latestSession.runtimeSessionId) {
763
+ throw new Error(`Latest session ${latestSession.id} has no runtime session ID — cannot resume`);
764
+ }
765
+ const conn = adapterManager.getConnection(latestSession.environmentId);
766
+ if (!conn) {
767
+ throw new Error(`Environment ${latestSession.environmentId} not connected`);
768
+ }
769
+ const powerlineReq = create(powerline.ResumeRequestSchema, {
770
+ sessionId: latestSession.id,
771
+ runtimeSessionId: latestSession.runtimeSessionId,
772
+ runtime: latestSession.runtime,
773
+ });
774
+ const logPath = latestSession.logPath || join(grackleHome, LOGS_DIR, latestSession.id);
775
+ processEventStream(conn.client.resume(powerlineReq), {
776
+ sessionId: latestSession.id,
777
+ logPath,
778
+ projectId: task.projectId,
779
+ taskId: task.id,
780
+ });
752
781
  broadcast({
753
- type: "task_rejected",
754
- payload: { taskId: task.id, projectId: task.projectId },
782
+ type: "task_started",
783
+ payload: { taskId: task.id, sessionId: latestSession.id, projectId: task.projectId },
755
784
  });
756
- const row = taskStore.getTask(task.id);
757
- return taskRowToProto(row);
785
+ const row = sessionStore.getSession(latestSession.id);
786
+ return sessionRowToProto(row);
758
787
  },
759
788
  async deleteTask(req) {
760
789
  const task = taskStore.getTask(req.id);
@@ -765,28 +794,26 @@ export function registerGrackleRoutes(router) {
765
794
  if (children.length > 0) {
766
795
  throw new ConnectError("Cannot delete task with children. Delete children first.", Code.FailedPrecondition);
767
796
  }
768
- // Kill active session before deleting the task
769
- if (task.sessionId) {
770
- const activeSession = sessionStore.getSession(task.sessionId);
771
- if (activeSession && (activeSession.status === "running" || activeSession.status === "waiting_input")) {
772
- const conn = adapterManager.getConnection(activeSession.environmentId);
773
- if (conn) {
774
- try {
775
- await conn.client.kill(create(powerline.SessionIdSchema, { id: task.sessionId }));
776
- }
777
- catch (err) {
778
- logger.warn({ taskId: req.id, sessionId: task.sessionId, err }, "Failed to kill session during task deletion");
779
- }
797
+ // Kill all active sessions before deleting the task
798
+ const activeSessions = sessionStore.getActiveSessionsForTask(req.id);
799
+ for (const activeSession of activeSessions) {
800
+ const conn = adapterManager.getConnection(activeSession.environmentId);
801
+ if (conn) {
802
+ try {
803
+ await conn.client.kill(create(powerline.SessionIdSchema, { id: activeSession.id }));
804
+ }
805
+ catch (err) {
806
+ logger.warn({ taskId: req.id, sessionId: activeSession.id, err }, "Failed to kill session during task deletion");
780
807
  }
781
- sessionStore.updateSession(task.sessionId, "killed");
782
- streamHub.publish(create(grackle.SessionEventSchema, {
783
- sessionId: task.sessionId,
784
- type: grackle.EventType.STATUS,
785
- timestamp: new Date().toISOString(),
786
- content: "killed",
787
- raw: "",
788
- }));
789
808
  }
809
+ sessionStore.updateSession(activeSession.id, SESSION_STATUS.INTERRUPTED);
810
+ streamHub.publish(create(grackle.SessionEventSchema, {
811
+ sessionId: activeSession.id,
812
+ type: grackle.EventType.STATUS,
813
+ timestamp: new Date().toISOString(),
814
+ content: SESSION_STATUS.INTERRUPTED,
815
+ raw: "",
816
+ }));
790
817
  }
791
818
  const changes = taskStore.deleteTask(req.id);
792
819
  if (changes === 0) {
@@ -847,10 +874,8 @@ export function registerGrackleRoutes(router) {
847
874
  // Only update toolConfig/mcpServers if the request provides non-empty values;
848
875
  // otherwise keep the existing stored value.
849
876
  const hasNewToolConfig = !!req.toolConfig &&
850
- ((req.toolConfig.allowedTools &&
851
- req.toolConfig.allowedTools.length > 0) ||
852
- (req.toolConfig.disallowedTools &&
853
- req.toolConfig.disallowedTools.length > 0));
877
+ (req.toolConfig.allowedTools.length > 0 ||
878
+ req.toolConfig.disallowedTools.length > 0);
854
879
  const toolConfigJson = hasNewToolConfig
855
880
  ? JSON.stringify({
856
881
  allowedTools: [...(req.toolConfig?.allowedTools || [])],