@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
package/dist/ws-bridge.js CHANGED
@@ -13,7 +13,7 @@ import * as findingStore from "./finding-store.js";
13
13
  import * as personaStore from "./persona-store.js";
14
14
  import { v4 as uuid } from "uuid";
15
15
  import { join } from "node:path";
16
- import { LOGS_DIR, DEFAULT_RUNTIME, DEFAULT_MODEL, eventTypeToString, } from "@grackle-ai/common";
16
+ import { LOGS_DIR, DEFAULT_RUNTIME, DEFAULT_MODEL, SESSION_STATUS, TASK_STATUS, eventTypeToString, } from "@grackle-ai/common";
17
17
  import { grackleHome } from "./paths.js";
18
18
  import * as logWriter from "./log-writer.js";
19
19
  import { safeParseJsonArray } from "./json-helpers.js";
@@ -22,9 +22,11 @@ import { buildTaskSystemContext } from "./utils/system-context.js";
22
22
  import { slugify } from "./utils/slugify.js";
23
23
  import { processEventStream } from "./event-processor.js";
24
24
  import * as processorRegistry from "./processor-registry.js";
25
- import { broadcast, setWssInstance } from "./ws-broadcast.js";
26
- import { buildMcpServersJson, makeTaskCompletionCallback } from "./grpc-service.js";
25
+ import { broadcast, setWssInstance, broadcastEnvironments, envRowToWs } from "./ws-broadcast.js";
26
+ import { buildMcpServersJson } from "./grpc-service.js";
27
+ import { computeTaskStatus } from "./compute-task-status.js";
27
28
  import { exec } from "./utils/exec.js";
29
+ import { formatGhError } from "./utils/format-gh-error.js";
28
30
  const GH_CODESPACE_LIST_TIMEOUT_MS = 30_000;
29
31
  const GH_CODESPACE_CREATE_TIMEOUT_MS = 300_000;
30
32
  const GH_CODESPACE_LIST_LIMIT = 50;
@@ -42,6 +44,7 @@ export function createWsBridge(httpServer, verifyApiKey) {
42
44
  return;
43
45
  }
44
46
  const subscriptions = new Map();
47
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
45
48
  ws.on("message", async (data) => {
46
49
  try {
47
50
  const msg = JSON.parse(data.toString());
@@ -65,25 +68,6 @@ export function createWsBridge(httpServer, verifyApiKey) {
65
68
  });
66
69
  return wss;
67
70
  }
68
- /** Map a database environment row to the WebSocket payload shape. */
69
- function envRowToWs(r) {
70
- return {
71
- id: r.id,
72
- displayName: r.displayName,
73
- adapterType: r.adapterType,
74
- adapterConfig: r.adapterConfig,
75
- defaultRuntime: r.defaultRuntime,
76
- status: r.status,
77
- bootstrapped: r.bootstrapped,
78
- };
79
- }
80
- /** Broadcast the current environment list to all connected WebSocket clients. */
81
- function broadcastEnvironments() {
82
- broadcast({
83
- type: "environments",
84
- payload: { environments: envRegistry.listEnvironments().map(envRowToWs) },
85
- });
86
- }
87
71
  /** Safely parse an adapter config string, returning an empty object on failure. */
88
72
  function safeParseAdapterConfig(raw, environmentId) {
89
73
  try {
@@ -191,7 +175,7 @@ async function startTaskSession(ws, task, options) {
191
175
  logger.warn({ taskId: task.id }, "startTaskSession failed: project not found");
192
176
  return `Project not found: ${task.projectId}`;
193
177
  }
194
- const environmentId = task.environmentId || project.defaultEnvironmentId;
178
+ const environmentId = options?.environmentId || project.defaultEnvironmentId;
195
179
  const env = envRegistry.getEnvironment(environmentId);
196
180
  if (!env) {
197
181
  logger.warn({ taskId: task.id, environmentId }, "startTaskSession failed: environment not found");
@@ -204,7 +188,7 @@ async function startTaskSession(ws, task, options) {
204
188
  return undefined;
205
189
  }
206
190
  // Resolve persona
207
- const resolvedPersonaId = options?.personaId || task.personaId;
191
+ const resolvedPersonaId = options?.personaId || "";
208
192
  const persona = resolvedPersonaId
209
193
  ? personaStore.getPersona(resolvedPersonaId)
210
194
  : undefined;
@@ -212,24 +196,17 @@ async function startTaskSession(ws, task, options) {
212
196
  return `Persona not found: ${resolvedPersonaId}`;
213
197
  }
214
198
  const sessionId = uuid();
215
- const runtime = options?.runtime ||
216
- persona?.runtime ||
217
- env.defaultRuntime ||
218
- DEFAULT_RUNTIME;
219
- const model = options?.model ||
220
- persona?.model ||
221
- process.env.GRACKLE_DEFAULT_MODEL ||
222
- DEFAULT_MODEL;
199
+ const runtime = options?.runtime || persona?.runtime || env.defaultRuntime || DEFAULT_RUNTIME;
200
+ const model = options?.model || persona?.model ||
201
+ process.env.GRACKLE_DEFAULT_MODEL || DEFAULT_MODEL;
223
202
  const maxTurns = persona?.maxTurns || 0;
224
203
  const logPath = join(grackleHome, LOGS_DIR, sessionId);
225
204
  const freshTask = taskStore.getTask(task.id) || task;
226
- let systemContext = buildTaskSystemContext(freshTask.title, freshTask.description, freshTask.reviewNotes, freshTask.canDecompose);
205
+ let systemContext = buildTaskSystemContext(freshTask.title, freshTask.description, options?.notes || "", freshTask.canDecompose);
227
206
  if (persona) {
228
207
  systemContext = persona.systemPrompt + "\n\n" + systemContext;
229
208
  }
230
- sessionStore.createSession(sessionId, environmentId, runtime, freshTask.title, model, logPath, freshTask.id);
231
- taskStore.setTaskSession(freshTask.id, sessionId);
232
- taskStore.markTaskStarted(freshTask.id);
209
+ sessionStore.createSession(sessionId, environmentId, runtime, freshTask.title, model, logPath, freshTask.id, resolvedPersonaId);
233
210
  broadcast({
234
211
  type: "task_started",
235
212
  payload: {
@@ -263,7 +240,7 @@ async function startTaskSession(ws, task, options) {
263
240
  maxTurns,
264
241
  branch: freshTask.branch,
265
242
  worktreeBasePath: freshTask.branch
266
- ? process.env.GRACKLE_WORKTREE_BASE || "/workspace"
243
+ ? (project.worktreeBasePath || process.env.GRACKLE_WORKTREE_BASE || "/workspace")
267
244
  : "",
268
245
  systemContext,
269
246
  projectId: freshTask.projectId,
@@ -275,7 +252,6 @@ async function startTaskSession(ws, task, options) {
275
252
  logPath,
276
253
  projectId: freshTask.projectId,
277
254
  taskId: freshTask.id,
278
- onComplete: makeTaskCompletionCallback(freshTask.id, sessionId, freshTask.projectId),
279
255
  });
280
256
  return undefined;
281
257
  }
@@ -314,7 +290,7 @@ async function handleMessage(ws, msg, subscriptions) {
314
290
  return;
315
291
  }
316
292
  const session = sessionStore.getSession(sessionId);
317
- if (!session || !session.logPath) {
293
+ if (!session?.logPath) {
318
294
  return;
319
295
  }
320
296
  const entries = logWriter.readLog(session.logPath);
@@ -388,8 +364,8 @@ async function handleMessage(ws, msg, subscriptions) {
388
364
  case "spawn": {
389
365
  const environmentId = msg.payload?.environmentId;
390
366
  const prompt = msg.payload?.prompt;
391
- const model = msg.payload?.model || "";
392
- const runtime = msg.payload?.runtime || "";
367
+ const model = msg.payload?.model || undefined;
368
+ const runtime = msg.payload?.runtime || undefined;
393
369
  const branch = msg.payload?.branch || "";
394
370
  const systemContext = msg.payload?.systemContext || "";
395
371
  const spawnPersonaId = msg.payload?.personaId || "";
@@ -420,7 +396,9 @@ async function handleMessage(ws, msg, subscriptions) {
420
396
  return;
421
397
  }
422
398
  const sessionId = uuid();
399
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime/model may be undefined from WS payload
423
400
  const sessionRuntime = runtime || spawnPersona?.runtime || env.defaultRuntime || DEFAULT_RUNTIME;
401
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
424
402
  const sessionModel = model || spawnPersona?.model || process.env.GRACKLE_DEFAULT_MODEL || DEFAULT_MODEL;
425
403
  const maxTurns = spawnPersona?.maxTurns || 0;
426
404
  const logPath = join(grackleHome, LOGS_DIR, sessionId);
@@ -437,7 +415,9 @@ async function handleMessage(ws, msg, subscriptions) {
437
415
  model: sessionModel,
438
416
  maxTurns,
439
417
  branch,
440
- worktreeBasePath: branch ? "/workspace" : "",
418
+ worktreeBasePath: branch
419
+ ? ((typeof msg.payload?.worktreeBasePath === "string" ? msg.payload.worktreeBasePath.trim() : "") || process.env.GRACKLE_WORKTREE_BASE || "/workspace")
420
+ : "",
441
421
  systemContext: finalSystemContext,
442
422
  });
443
423
  processEventStream(conn.client.spawn(powerlineReq), {
@@ -450,7 +430,7 @@ async function handleMessage(ws, msg, subscriptions) {
450
430
  sessionId,
451
431
  eventType: "error",
452
432
  timestamp: new Date().toISOString(),
453
- content: `Spawn failed: ${err}`,
433
+ content: `Spawn failed: ${err instanceof Error ? err.message : String(err)}`,
454
434
  },
455
435
  });
456
436
  },
@@ -475,11 +455,11 @@ async function handleMessage(ws, msg, subscriptions) {
475
455
  });
476
456
  return;
477
457
  }
478
- if (session.status !== "waiting_input") {
458
+ if (session.status !== SESSION_STATUS.IDLE) {
479
459
  sendWs(ws, {
480
460
  type: "error",
481
461
  payload: {
482
- message: `Session ${sessionId} is not currently waiting for input (status: ${session.status})`,
462
+ message: `Session ${sessionId} is not currently idle (status: ${session.status})`,
483
463
  },
484
464
  });
485
465
  return;
@@ -532,21 +512,24 @@ async function handleMessage(ws, msg, subscriptions) {
532
512
  await conn.client.kill(create(powerline.SessionIdSchema, { id: sessionId }));
533
513
  }
534
514
  catch (err) {
535
- sendWs(ws, {
536
- type: "error",
537
- payload: { message: `Kill failed: ${err}` },
538
- });
539
- return;
515
+ logger.warn({ sessionId, err }, "PowerLine kill failed — marking session interrupted anyway");
540
516
  }
541
517
  }
542
- sessionStore.updateSession(sessionId, "killed");
518
+ sessionStore.updateSession(sessionId, SESSION_STATUS.INTERRUPTED);
543
519
  streamHub.publish(create(grackle.SessionEventSchema, {
544
520
  sessionId,
545
521
  type: grackle.EventType.STATUS,
546
522
  timestamp: new Date().toISOString(),
547
- content: "killed",
523
+ content: SESSION_STATUS.INTERRUPTED,
548
524
  raw: "",
549
525
  }));
526
+ // Broadcast task_updated so frontend re-fetches computed status
527
+ if (session.taskId) {
528
+ const task = taskStore.getTask(session.taskId);
529
+ if (task) {
530
+ broadcast({ type: "task_updated", payload: { taskId: task.id, projectId: task.projectId } });
531
+ }
532
+ }
550
533
  break;
551
534
  }
552
535
  // ─── Projects ──────────────────────────────────────────
@@ -563,6 +546,7 @@ async function handleMessage(ws, msg, subscriptions) {
563
546
  defaultEnvironmentId: r.defaultEnvironmentId,
564
547
  status: r.status,
565
548
  useWorktrees: r.useWorktrees,
549
+ worktreeBasePath: r.worktreeBasePath,
566
550
  createdAt: r.createdAt,
567
551
  updatedAt: r.updatedAt,
568
552
  })),
@@ -586,7 +570,7 @@ async function handleMessage(ws, msg, subscriptions) {
586
570
  }
587
571
  // useWorktrees defaults to true when not specified
588
572
  const createUseWorktrees = msg.payload?.useWorktrees ?? true;
589
- projectStore.createProject(id, name, msg.payload?.description || "", msg.payload?.repoUrl || "", msg.payload?.defaultEnvironmentId || "", createUseWorktrees);
573
+ projectStore.createProject(id, name, msg.payload?.description || "", msg.payload?.repoUrl || "", msg.payload?.defaultEnvironmentId || "", createUseWorktrees, typeof msg.payload?.worktreeBasePath === "string" ? msg.payload.worktreeBasePath.trim() : "");
590
574
  const row = projectStore.getProject(id);
591
575
  broadcast({ type: "project_created", payload: { project: row } });
592
576
  break;
@@ -610,7 +594,7 @@ async function handleMessage(ws, msg, subscriptions) {
610
594
  return;
611
595
  }
612
596
  const nameVal = typeof msg.payload?.name === "string" ? msg.payload.name : undefined;
613
- if (nameVal !== undefined && nameVal.trim() === "") {
597
+ if (nameVal?.trim() === "") {
614
598
  sendWs(ws, { type: "error", payload: { message: "Project name cannot be empty" } });
615
599
  return;
616
600
  }
@@ -622,12 +606,14 @@ async function handleMessage(ws, msg, subscriptions) {
622
606
  return;
623
607
  }
624
608
  const worktreesVal = typeof msg.payload?.useWorktrees === "boolean" ? msg.payload.useWorktrees : undefined;
609
+ const worktreeBasePathVal = typeof msg.payload?.worktreeBasePath === "string" ? msg.payload.worktreeBasePath : undefined;
625
610
  projectStore.updateProject(projectId, {
626
611
  name: nameVal !== undefined ? nameVal.trim() : undefined,
627
612
  description: descVal,
628
613
  repoUrl: repoVal,
629
614
  defaultEnvironmentId: envVal,
630
615
  useWorktrees: worktreesVal,
616
+ worktreeBasePath: worktreeBasePathVal,
631
617
  });
632
618
  broadcast({ type: "project_updated", payload: { projectId } });
633
619
  break;
@@ -735,29 +721,39 @@ async function handleMessage(ws, msg, subscriptions) {
735
721
  return;
736
722
  const rows = taskStore.listTasks(projectId);
737
723
  const childIdsMap = taskStore.buildChildIdsMap(rows);
724
+ // Batch-fetch sessions for all tasks and group by taskId
725
+ const taskIds = rows.map((r) => r.id);
726
+ const allSessions = sessionStore.listSessionsByTaskIds(taskIds);
727
+ const sessionsByTask = new Map();
728
+ for (const s of allSessions) {
729
+ const arr = sessionsByTask.get(s.taskId) ?? [];
730
+ arr.push(s);
731
+ sessionsByTask.set(s.taskId, arr);
732
+ }
738
733
  sendWs(ws, {
739
734
  type: "tasks",
740
735
  payload: {
741
736
  projectId,
742
- tasks: rows.map((r) => ({
743
- id: r.id,
744
- projectId: r.projectId,
745
- title: r.title,
746
- description: r.description,
747
- status: r.status,
748
- branch: r.branch,
749
- environmentId: r.environmentId,
750
- sessionId: r.sessionId,
751
- dependsOn: safeParseJsonArray(r.dependsOn),
752
- reviewNotes: r.reviewNotes,
753
- sortOrder: r.sortOrder,
754
- createdAt: r.createdAt,
755
- parentTaskId: r.parentTaskId,
756
- depth: r.depth,
757
- childTaskIds: childIdsMap.get(r.id) ?? [],
758
- canDecompose: r.canDecompose,
759
- personaId: r.personaId,
760
- })),
737
+ tasks: rows.map((r) => {
738
+ const taskSessions = sessionsByTask.get(r.id) ?? [];
739
+ const computed = computeTaskStatus(r.status, taskSessions);
740
+ return {
741
+ id: r.id,
742
+ projectId: r.projectId,
743
+ title: r.title,
744
+ description: r.description,
745
+ status: computed.status,
746
+ branch: r.branch,
747
+ latestSessionId: computed.latestSessionId,
748
+ dependsOn: safeParseJsonArray(r.dependsOn),
749
+ sortOrder: r.sortOrder,
750
+ createdAt: r.createdAt,
751
+ parentTaskId: r.parentTaskId,
752
+ depth: r.depth,
753
+ childTaskIds: childIdsMap.get(r.id) ?? [],
754
+ canDecompose: r.canDecompose,
755
+ };
756
+ }),
761
757
  },
762
758
  });
763
759
  break;
@@ -783,19 +779,8 @@ async function handleMessage(ws, msg, subscriptions) {
783
779
  const parentTaskId = msg.payload?.parentTaskId || "";
784
780
  const rawCanDecompose = msg.payload?.canDecompose;
785
781
  const canDecompose = typeof rawCanDecompose === "boolean" ? rawCanDecompose : undefined;
786
- // Resolve environment: explicit > parent task's env > project default
787
- let resolvedEnvId = msg.payload?.environmentId || "";
788
- if (!resolvedEnvId && parentTaskId) {
789
- const parentTask = taskStore.getTask(parentTaskId);
790
- if (parentTask?.environmentId) {
791
- resolvedEnvId = parentTask.environmentId;
792
- }
793
- }
794
- if (!resolvedEnvId) {
795
- resolvedEnvId = project.defaultEnvironmentId;
796
- }
797
782
  const id = uuid().slice(0, 8);
798
- taskStore.createTask(id, projectId, title, msg.payload?.description || "", resolvedEnvId, msg.payload?.dependsOn || [], slugify(project.name), parentTaskId, canDecompose, msg.payload?.personaId || "");
783
+ taskStore.createTask(id, projectId, title, msg.payload?.description || "", msg.payload?.dependsOn || [], slugify(project.name), parentTaskId, canDecompose);
799
784
  const row = taskStore.getTask(id);
800
785
  broadcast({
801
786
  type: "task_created",
@@ -826,7 +811,7 @@ async function handleMessage(ws, msg, subscriptions) {
826
811
  sendWs(ws, { type: "error", payload: { message: `Session not found: ${lateBindSessionId}` } });
827
812
  return;
828
813
  }
829
- const terminalStatuses = ["completed", "failed", "killed"];
814
+ const terminalStatuses = [SESSION_STATUS.COMPLETED, SESSION_STATUS.FAILED, SESSION_STATUS.INTERRUPTED];
830
815
  if (terminalStatuses.includes(session.status)) {
831
816
  sendWs(ws, {
832
817
  type: "error",
@@ -843,11 +828,8 @@ async function handleMessage(ws, msg, subscriptions) {
843
828
  return;
844
829
  }
845
830
  sessionStore.setSessionTask(lateBindSessionId, updateTaskId);
846
- taskStore.setTaskSession(updateTaskId, lateBindSessionId);
847
- taskStore.markTaskStarted(updateTaskId);
848
- const onComplete = makeTaskCompletionCallback(updateTaskId, lateBindSessionId, existingTask.projectId);
849
831
  try {
850
- processorRegistry.lateBind(lateBindSessionId, updateTaskId, existingTask.projectId, onComplete);
832
+ processorRegistry.lateBind(lateBindSessionId, updateTaskId, existingTask.projectId);
851
833
  }
852
834
  catch (err) {
853
835
  sendWs(ws, { type: "error", payload: { message: String(err) } });
@@ -859,8 +841,8 @@ async function handleMessage(ws, msg, subscriptions) {
859
841
  });
860
842
  break;
861
843
  }
862
- // Only allow editing pending/assigned tasks (non-late-bind path)
863
- if (!["pending", "assigned"].includes(existingTask.status)) {
844
+ // Only allow editing not_started tasks (non-late-bind path)
845
+ if (existingTask.status !== TASK_STATUS.NOT_STARTED) {
864
846
  sendWs(ws, {
865
847
  type: "error",
866
848
  payload: { message: `Task ${updateTaskId} cannot be edited (status: ${existingTask.status})` },
@@ -881,13 +863,7 @@ async function handleMessage(ws, msg, subscriptions) {
881
863
  .filter((d) => d !== updateTaskId)),
882
864
  ]
883
865
  : safeParseJsonArray(existingTask.dependsOn);
884
- const updatedEnvironmentId = typeof msg.payload?.environmentId === "string"
885
- ? msg.payload.environmentId
886
- : existingTask.environmentId;
887
- const updatedPersonaId = typeof msg.payload?.personaId === "string"
888
- ? msg.payload.personaId
889
- : existingTask.personaId;
890
- taskStore.updateTask(updateTaskId, updatedTitle, updatedDescription, existingTask.status, updatedEnvironmentId, updatedDependsOn, existingTask.reviewNotes, updatedPersonaId);
866
+ taskStore.updateTask(updateTaskId, updatedTitle, updatedDescription, existingTask.status, updatedDependsOn);
891
867
  const updatedRow = taskStore.getTask(updateTaskId);
892
868
  broadcast({
893
869
  type: "task_updated",
@@ -913,14 +889,18 @@ async function handleMessage(ws, msg, subscriptions) {
913
889
  });
914
890
  return;
915
891
  }
916
- if (!["pending", "assigned", "failed"].includes(task.status)) {
917
- sendWs(ws, {
918
- type: "error",
919
- payload: {
920
- message: `Task cannot be started (status: ${task.status})`,
921
- },
922
- });
923
- return;
892
+ {
893
+ const taskSessions = sessionStore.listSessionsForTask(taskId);
894
+ const { status: effectiveStatus } = computeTaskStatus(task.status, taskSessions);
895
+ if (![TASK_STATUS.NOT_STARTED, TASK_STATUS.FAILED].includes(effectiveStatus)) {
896
+ sendWs(ws, {
897
+ type: "error",
898
+ payload: {
899
+ message: `Task cannot be started (status: ${effectiveStatus})`,
900
+ },
901
+ });
902
+ return;
903
+ }
924
904
  }
925
905
  if (!taskStore.areDependenciesMet(taskId)) {
926
906
  sendWs(ws, {
@@ -932,68 +912,90 @@ async function handleMessage(ws, msg, subscriptions) {
932
912
  const startError = await startTaskSession(ws, task, {
933
913
  runtime: msg.payload?.runtime,
934
914
  model: msg.payload?.model,
935
- personaId: msg.payload?.personaId || task.personaId || undefined,
915
+ personaId: msg.payload?.personaId || undefined,
916
+ environmentId: msg.payload?.environmentId || undefined,
917
+ notes: msg.payload?.notes || undefined,
936
918
  });
937
919
  if (startError) {
938
920
  sendWs(ws, { type: "error", payload: { message: startError } });
939
921
  }
940
922
  break;
941
923
  }
942
- case "approve_task": {
924
+ case "complete_task": {
943
925
  const taskId = msg.payload?.taskId;
944
926
  if (!taskId)
945
927
  return;
946
- taskStore.markTaskCompleted(taskId, "done");
928
+ taskStore.markTaskComplete(taskId, TASK_STATUS.COMPLETE);
947
929
  const task = taskStore.getTask(taskId);
948
930
  const unblocked = task ? taskStore.checkAndUnblock(task.projectId) : [];
949
931
  sendWs(ws, {
950
- type: "task_approved",
932
+ type: "task_completed",
951
933
  payload: {
952
934
  taskId,
953
935
  unblockedTaskIds: unblocked.map((t) => t.id),
954
936
  },
955
937
  });
938
+ if (task) {
939
+ broadcast({
940
+ type: "task_completed",
941
+ payload: { taskId, projectId: task.projectId },
942
+ });
943
+ }
956
944
  break;
957
945
  }
958
- case "reject_task": {
946
+ case "resume_task": {
959
947
  const taskId = msg.payload?.taskId;
960
- const reviewNotes = msg.payload?.reviewNotes || "";
961
948
  if (!taskId)
962
949
  return;
963
950
  const task = taskStore.getTask(taskId);
964
- if (!task)
951
+ if (!task) {
952
+ sendWs(ws, { type: "error", payload: { message: `Task not found: ${taskId}` } });
965
953
  return;
966
- if (task.status !== "review") {
954
+ }
955
+ const latestSession = sessionStore.getLatestSessionForTask(taskId);
956
+ if (!latestSession) {
957
+ sendWs(ws, { type: "error", payload: { message: `Task ${taskId} has no sessions to resume` } });
958
+ return;
959
+ }
960
+ if (![SESSION_STATUS.INTERRUPTED, SESSION_STATUS.COMPLETED].includes(latestSession.status)) {
967
961
  sendWs(ws, {
968
962
  type: "error",
969
- payload: {
970
- message: `Task cannot be rejected (status: ${task.status})`,
971
- },
963
+ payload: { message: `Latest session ${latestSession.id} is not resumable (status: ${latestSession.status})` },
972
964
  });
973
965
  return;
974
966
  }
975
- // Preserve runtime/model from the previous session so the retry
976
- // doesn't unexpectedly switch runtimes/models.
977
- const previousSession = task.sessionId
978
- ? sessionStore.getSession(task.sessionId)
979
- : undefined;
980
- // Store review notes and set status to assigned
981
- taskStore.updateTask(task.id, task.title, task.description, "assigned", task.environmentId, safeParseJsonArray(task.dependsOn), reviewNotes, task.personaId);
982
- broadcast({
983
- type: "task_rejected",
984
- payload: { taskId, projectId: task.projectId },
985
- });
986
- // Auto-retry: start a new session with the review feedback
987
- const freshTask = taskStore.getTask(taskId);
988
- if (freshTask) {
989
- const retryError = await startTaskSession(ws, freshTask, {
990
- runtime: previousSession?.runtime,
991
- model: previousSession?.model,
967
+ if (!latestSession.runtimeSessionId) {
968
+ sendWs(ws, {
969
+ type: "error",
970
+ payload: { message: `Latest session ${latestSession.id} has no runtime session ID — cannot resume` },
992
971
  });
993
- if (retryError) {
994
- logger.warn({ taskId, error: retryError }, "Auto-retry after rejection failed — task remains assigned");
995
- }
972
+ return;
973
+ }
974
+ const env = envRegistry.getEnvironment(latestSession.environmentId);
975
+ if (!env) {
976
+ sendWs(ws, { type: "error", payload: { message: `Environment not found: ${latestSession.environmentId}` } });
977
+ return;
996
978
  }
979
+ const conn = await autoProvisionEnvironment(ws, latestSession.environmentId, env, { taskId });
980
+ if (!conn) {
981
+ return;
982
+ }
983
+ const powerlineReq = create(powerline.ResumeRequestSchema, {
984
+ sessionId: latestSession.id,
985
+ runtimeSessionId: latestSession.runtimeSessionId,
986
+ runtime: latestSession.runtime,
987
+ });
988
+ const logPath = latestSession.logPath || join(grackleHome, LOGS_DIR, latestSession.id);
989
+ processEventStream(conn.client.resume(powerlineReq), {
990
+ sessionId: latestSession.id,
991
+ logPath,
992
+ projectId: task.projectId,
993
+ taskId: task.id,
994
+ });
995
+ broadcast({
996
+ type: "task_started",
997
+ payload: { taskId: task.id, sessionId: latestSession.id, projectId: task.projectId },
998
+ });
997
999
  break;
998
1000
  }
999
1001
  case "delete_task": {
@@ -1015,28 +1017,26 @@ async function handleMessage(ws, msg, subscriptions) {
1015
1017
  });
1016
1018
  return;
1017
1019
  }
1018
- // Kill active session before deleting the task
1019
- if (deletedTask.sessionId) {
1020
- const activeSession = sessionStore.getSession(deletedTask.sessionId);
1021
- if (activeSession && (activeSession.status === "running" || activeSession.status === "waiting_input")) {
1022
- const conn = adapterManager.getConnection(activeSession.environmentId);
1023
- if (conn) {
1024
- try {
1025
- await conn.client.kill(create(powerline.SessionIdSchema, { id: deletedTask.sessionId }));
1026
- }
1027
- catch (err) {
1028
- logger.warn({ taskId, sessionId: deletedTask.sessionId, err }, "Failed to kill session during task deletion");
1029
- }
1020
+ // Kill all active sessions before deleting the task
1021
+ const activeSessions = sessionStore.getActiveSessionsForTask(taskId);
1022
+ for (const activeSession of activeSessions) {
1023
+ const conn = adapterManager.getConnection(activeSession.environmentId);
1024
+ if (conn) {
1025
+ try {
1026
+ await conn.client.kill(create(powerline.SessionIdSchema, { id: activeSession.id }));
1027
+ }
1028
+ catch (err) {
1029
+ logger.warn({ taskId, sessionId: activeSession.id, err }, "Failed to kill session during task deletion");
1030
1030
  }
1031
- sessionStore.updateSession(deletedTask.sessionId, "killed");
1032
- streamHub.publish(create(grackle.SessionEventSchema, {
1033
- sessionId: deletedTask.sessionId,
1034
- type: grackle.EventType.STATUS,
1035
- timestamp: new Date().toISOString(),
1036
- content: "killed",
1037
- raw: "",
1038
- }));
1039
1031
  }
1032
+ sessionStore.updateSession(activeSession.id, SESSION_STATUS.INTERRUPTED);
1033
+ streamHub.publish(create(grackle.SessionEventSchema, {
1034
+ sessionId: activeSession.id,
1035
+ type: grackle.EventType.STATUS,
1036
+ timestamp: new Date().toISOString(),
1037
+ content: SESSION_STATUS.INTERRUPTED,
1038
+ raw: "",
1039
+ }));
1040
1040
  }
1041
1041
  const changes = taskStore.deleteTask(taskId);
1042
1042
  if (changes === 0) {
@@ -1118,15 +1118,14 @@ async function handleMessage(ws, msg, subscriptions) {
1118
1118
  if (!taskId)
1119
1119
  return;
1120
1120
  const task = taskStore.getTask(taskId);
1121
- if (!task || !task.branch) {
1121
+ if (!task?.branch) {
1122
1122
  sendWs(ws, {
1123
1123
  type: "task_diff",
1124
1124
  payload: { taskId, error: "No branch" },
1125
1125
  });
1126
1126
  return;
1127
1127
  }
1128
- const environmentId = task.environmentId ||
1129
- projectStore.getProject(task.projectId)?.defaultEnvironmentId;
1128
+ const environmentId = projectStore.getProject(task.projectId)?.defaultEnvironmentId;
1130
1129
  if (!environmentId) {
1131
1130
  sendWs(ws, {
1132
1131
  type: "task_diff",
@@ -1395,7 +1394,10 @@ async function handleMessage(ws, msg, subscriptions) {
1395
1394
  logger.warn({ err }, "Failed to list codespaces");
1396
1395
  sendWs(ws, {
1397
1396
  type: "codespaces_list",
1398
- payload: { codespaces: [], error: String(err) },
1397
+ payload: {
1398
+ codespaces: [],
1399
+ error: formatGhError(err, "list codespaces"),
1400
+ },
1399
1401
  });
1400
1402
  }
1401
1403
  break;
@@ -1410,15 +1412,17 @@ async function handleMessage(ws, msg, subscriptions) {
1410
1412
  return;
1411
1413
  }
1412
1414
  const trimmedRepo = repo.trim();
1415
+ const machine = typeof msg.payload?.machine === "string"
1416
+ ? msg.payload.machine.trim()
1417
+ : "";
1418
+ const createArgs = ["codespace", "create", "--repo", trimmedRepo];
1419
+ if (machine) {
1420
+ createArgs.push("--machine", machine);
1421
+ }
1413
1422
  try {
1414
- const result = await exec("gh", [
1415
- "codespace",
1416
- "create",
1417
- "--repo",
1418
- trimmedRepo,
1419
- "--machine",
1420
- "basicLinux32gb",
1421
- ], { timeout: GH_CODESPACE_CREATE_TIMEOUT_MS });
1423
+ const result = await exec("gh", createArgs, {
1424
+ timeout: GH_CODESPACE_CREATE_TIMEOUT_MS,
1425
+ });
1422
1426
  const codespaceName = result.stdout.trim();
1423
1427
  sendWs(ws, {
1424
1428
  type: "codespace_created",
@@ -1429,7 +1433,7 @@ async function handleMessage(ws, msg, subscriptions) {
1429
1433
  logger.error({ err, repo }, "Failed to create codespace");
1430
1434
  sendWs(ws, {
1431
1435
  type: "codespace_create_error",
1432
- payload: { message: String(err) },
1436
+ payload: { message: formatGhError(err, "create codespace") },
1433
1437
  });
1434
1438
  }
1435
1439
  break;