@gajae-code/coding-agent 0.11.11 → 0.12.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 (182) hide show
  1. package/CHANGELOG.md +49 -3
  2. package/dist/types/cli/read-cli.d.ts +1 -0
  3. package/dist/types/commands/deep-interview.d.ts +9 -0
  4. package/dist/types/commands/harness.d.ts +22 -0
  5. package/dist/types/commands/read.d.ts +6 -0
  6. package/dist/types/config/model-registry.d.ts +4 -0
  7. package/dist/types/config/provider-auth-health.d.ts +14 -0
  8. package/dist/types/config/provider-ranking.d.ts +59 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/config/settings.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/types.d.ts +22 -2
  12. package/dist/types/extensibility/shared-events.d.ts +6 -0
  13. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  14. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  15. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  16. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  17. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  18. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  19. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +52 -1
  20. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  21. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  22. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
  23. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  24. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  25. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +7 -0
  26. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  27. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  28. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  29. package/dist/types/main.d.ts +1 -1
  30. package/dist/types/memories/index.d.ts +2 -0
  31. package/dist/types/modes/acp/acp-agent.d.ts +8 -0
  32. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  33. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  34. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  35. package/dist/types/modes/components/session-observer-overlay.d.ts +4 -0
  36. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  37. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  38. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  39. package/dist/types/modes/interactive-mode.d.ts +2 -0
  40. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  41. package/dist/types/modes/types.d.ts +2 -0
  42. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  43. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  44. package/dist/types/sdk/broker/lifecycle.d.ts +5 -0
  45. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  46. package/dist/types/sdk/bus/index.d.ts +15 -1
  47. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +37 -0
  48. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  49. package/dist/types/sdk/bus/reconciliation-store.d.ts +65 -0
  50. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  51. package/dist/types/sdk/host/host.d.ts +2 -0
  52. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  53. package/dist/types/session/agent-session.d.ts +44 -10
  54. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  55. package/dist/types/session/session-manager.d.ts +25 -13
  56. package/dist/types/session/streaming-output.d.ts +60 -5
  57. package/dist/types/skill-state/active-state.d.ts +0 -4
  58. package/dist/types/skill-state/workflow-hud.d.ts +3 -0
  59. package/dist/types/tools/browser/launch.d.ts +7 -0
  60. package/dist/types/tools/fetch.d.ts +17 -1
  61. package/dist/types/tools/output-meta.d.ts +21 -1
  62. package/dist/types/tools/read-internals.d.ts +12 -0
  63. package/dist/types/tools/read.d.ts +45 -1
  64. package/dist/types/tools/resource-gc.d.ts +4 -0
  65. package/dist/types/tools/tool-result.d.ts +7 -1
  66. package/package.json +9 -13
  67. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  68. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  69. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  70. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  71. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  72. package/src/capability/index.ts +8 -0
  73. package/src/cli/read-cli.ts +2 -1
  74. package/src/cli.ts +1 -0
  75. package/src/commands/deep-interview.ts +29 -9
  76. package/src/commands/harness.ts +35 -12
  77. package/src/commands/read.ts +11 -2
  78. package/src/commands/team.ts +1 -1
  79. package/src/config/model-registry.ts +21 -2
  80. package/src/config/provider-auth-health.ts +42 -0
  81. package/src/config/provider-ranking.ts +119 -0
  82. package/src/config/settings-schema.ts +23 -1
  83. package/src/config/settings.ts +246 -108
  84. package/src/defaults/gjc/skills/deep-interview/SKILL.md +229 -82
  85. package/src/defaults/gjc/skills/ralplan/SKILL.md +58 -25
  86. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  87. package/src/defaults/gjc/skills/ultragoal/SKILL.md +25 -15
  88. package/src/eval/py/executor.ts +7 -2
  89. package/src/extensibility/extensions/types.ts +28 -4
  90. package/src/extensibility/shared-events.ts +5 -0
  91. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -82
  92. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  93. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  94. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  95. package/src/gjc-runtime/deep-interview-state.ts +12 -943
  96. package/src/gjc-runtime/gc-render.ts +1 -0
  97. package/src/gjc-runtime/gc-runtime.ts +11 -1
  98. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  99. package/src/gjc-runtime/ralplan-runtime.ts +742 -85
  100. package/src/gjc-runtime/state-runtime.ts +2 -1
  101. package/src/gjc-runtime/state-writer.ts +70 -222
  102. package/src/gjc-runtime/team-launch.ts +34 -5
  103. package/src/gjc-runtime/team-runtime.ts +970 -33
  104. package/src/gjc-runtime/team-store.ts +32 -0
  105. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  106. package/src/gjc-runtime/team-workers.ts +1 -0
  107. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -0
  108. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  109. package/src/gjc-runtime/ultragoal-runtime.ts +13 -7
  110. package/src/gjc-runtime/workflow-manifest.generated.json +97 -350
  111. package/src/gjc-runtime/workflow-manifest.ts +34 -179
  112. package/src/hooks/native-skill-hook.ts +17 -4
  113. package/src/hooks/skill-state.ts +1 -23
  114. package/src/internal-urls/docs-index.generated.ts +8 -9
  115. package/src/internal-urls/local-root-gc.ts +127 -0
  116. package/src/main.ts +9 -5
  117. package/src/memories/index.ts +10 -0
  118. package/src/modes/DESIGN.md +47 -0
  119. package/src/modes/acp/acp-agent.ts +43 -7
  120. package/src/modes/acp/acp-event-mapper.ts +14 -7
  121. package/src/modes/acp/acp-mode.ts +39 -3
  122. package/src/modes/components/assistant-message.ts +123 -22
  123. package/src/modes/components/irc-sidebar.ts +176 -62
  124. package/src/modes/components/model-selector.ts +61 -4
  125. package/src/modes/components/oauth-selector.ts +42 -12
  126. package/src/modes/components/read-tool-group.ts +82 -14
  127. package/src/modes/components/session-observer-overlay.ts +187 -32
  128. package/src/modes/components/settings-selector.ts +60 -18
  129. package/src/modes/components/tool-execution.ts +138 -27
  130. package/src/modes/controllers/event-controller.ts +109 -27
  131. package/src/modes/controllers/extension-ui-controller.ts +19 -2
  132. package/src/modes/controllers/input-controller.ts +18 -3
  133. package/src/modes/controllers/selector-controller.ts +83 -37
  134. package/src/modes/interactive-mode.ts +62 -3
  135. package/src/modes/irc-observation-ledger.ts +71 -15
  136. package/src/modes/runtime-init.ts +19 -2
  137. package/src/modes/types.ts +2 -0
  138. package/src/modes/utils/ui-helpers.ts +94 -1
  139. package/src/prompts/agents/architect.md +9 -1
  140. package/src/prompts/agents/critic.md +9 -0
  141. package/src/prompts/system/system-prompt.md +0 -1
  142. package/src/prompts/tools/read.md +4 -2
  143. package/src/runtime-mcp/smithery-auth.ts +17 -2
  144. package/src/runtime-mcp/smithery-connect.ts +11 -1
  145. package/src/sdk/broker/lifecycle.ts +25 -6
  146. package/src/sdk/bus/control-drain-lease.ts +104 -0
  147. package/src/sdk/bus/index.ts +341 -38
  148. package/src/sdk/bus/kind-aware-reconciliation.ts +242 -0
  149. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  150. package/src/sdk/bus/reconciliation-store.ts +224 -0
  151. package/src/sdk/host/control/dispatch.ts +5 -1
  152. package/src/sdk/host/control/operations.ts +1 -1
  153. package/src/sdk/host/host.ts +23 -1
  154. package/src/sdk/host/query/handlers.ts +55 -0
  155. package/src/sdk/mcp/server.ts +46 -18
  156. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  157. package/src/sdk/protocol/operation-registry.ts +7 -2
  158. package/src/sdk/session.ts +13 -1
  159. package/src/session/agent-session.ts +466 -50
  160. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  161. package/src/session/session-manager.ts +764 -17
  162. package/src/session/streaming-output.ts +262 -43
  163. package/src/setup/provider-onboarding.ts +19 -4
  164. package/src/skill-state/active-state.ts +0 -10
  165. package/src/skill-state/workflow-hud.ts +25 -2
  166. package/src/skill-state/workflow-mutation-guard.ts +65 -7
  167. package/src/tools/ask.ts +89 -69
  168. package/src/tools/browser/launch.ts +41 -6
  169. package/src/tools/fetch.ts +114 -23
  170. package/src/tools/output-meta.ts +176 -9
  171. package/src/tools/path-utils.ts +5 -1
  172. package/src/tools/read-internals.ts +44 -0
  173. package/src/tools/read.ts +1542 -310
  174. package/src/tools/resource-gc.ts +182 -18
  175. package/src/tools/tool-result.ts +14 -1
  176. package/src/web/search/providers/codex.ts +1 -1
  177. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  178. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  179. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  180. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  181. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  182. package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
@@ -43,6 +43,7 @@ import type {
43
43
  GjcTeamTaskStatus,
44
44
  } from "./team-store";
45
45
  import {
46
+ findGjcTeamClaimedTaskForWorker,
46
47
  GjcTeamTaskStore,
47
48
  isCanonicalPersistedGjcTeamTask,
48
49
  isCanonicalPersistedGjcTeamTaskClaim,
@@ -52,7 +53,19 @@ import {
52
53
  withGjcTeamMutationFence,
53
54
  withGjcTeamTaskMutation,
54
55
  } from "./team-store";
55
-
56
+ import {
57
+ appendTeamWorkerMemoryGuardLedgerEntry,
58
+ createInitialGjcTeamWorkerMemoryGuardLedger,
59
+ type GjcTeamWorkerMemoryGuardCheckpoint,
60
+ type GjcTeamWorkerMemoryGuardLedger,
61
+ type GjcTeamWorkerMemoryGuardSelectionCandidate,
62
+ isCanonicalGjcTeamWorkerMemoryGuardLedger,
63
+ nextTeamWorkerMemoryGuardAttempt,
64
+ normalizeGjcTeamWorkerMemoryGuardPidProbe,
65
+ readTeamWorkerMemoryGuardLedger,
66
+ selectGjcTeamWorkerMemoryGuardCandidate,
67
+ workerMemoryGuardLedgerPath,
68
+ } from "./team-worker-memory-guard";
56
69
  import {
57
70
  GJC_TEAM_CONTINUATION_PROMPT,
58
71
  type GjcTeamWorkerOrchestrationRuntime,
@@ -70,10 +83,10 @@ import {
70
83
  updateGjcWorkerHeartbeat as updateWorkerHeartbeat,
71
84
  updateGjcWorkerStatus as updateWorkerStatus,
72
85
  writeWorkerLifecycleForConfig as writeLifecycleForConfig,
86
+ writeWorkerLifecycleRecord as writeLifecycleRecord,
73
87
  writeGjcShutdownRequest as writeShutdownRequest,
74
88
  writeGjcWorkerStartupAck as writeWorkerStartupAck,
75
89
  } from "./team-workers";
76
-
77
90
  import {
78
91
  buildGjcTmuxExactOptionTarget,
79
92
  buildGjcTmuxUntaggedSessionHint,
@@ -435,6 +448,7 @@ export interface WorkerHeartbeatFile {
435
448
  last_turn_at: string;
436
449
  turn_count: number;
437
450
  alive: boolean;
451
+ process_start_time?: string;
438
452
  }
439
453
  interface GitResult {
440
454
  ok: boolean;
@@ -520,6 +534,9 @@ export const GJC_TEAM_API_OPERATIONS = [
520
534
  "read-worker-heartbeat",
521
535
  "recover-stale-claims",
522
536
  "update-worker-heartbeat",
537
+ "read-worker-memory-guard",
538
+ "update-worker-memory-guard",
539
+ "apply-worker-memory-guard",
523
540
  "write-worker-inbox",
524
541
  "write-worker-identity",
525
542
  "append-event",
@@ -1218,6 +1235,821 @@ async function readConfigForWorkerIntegration(dir: string): Promise<GjcTeamConfi
1218
1235
 
1219
1236
  return config;
1220
1237
  }
1238
+ function manifestPath(dir: string): string {
1239
+ return path.join(dir, "manifest.v2.json");
1240
+ }
1241
+
1242
+ function manifestRecordFromConfig(config: GjcTeamConfig): Record<string, unknown> {
1243
+ return {
1244
+ version: 2,
1245
+ team_name: config.team_name,
1246
+ display_name: config.display_name,
1247
+ requested_name: config.requested_name,
1248
+ tmux_session: config.tmux_session,
1249
+ tmux_session_name: config.tmux_session_name,
1250
+ tmux_target: config.tmux_target,
1251
+ worker_command: config.worker_command,
1252
+ worker_cli_plan: config.worker_cli_plan,
1253
+ tmux_command: config.tmux_command,
1254
+ leader: config.leader,
1255
+ workers: config.workers,
1256
+ workspace_mode: config.workspace_mode,
1257
+ dry_run: config.dry_run,
1258
+ created_at: config.created_at,
1259
+ updated_at: config.updated_at,
1260
+ };
1261
+ }
1262
+
1263
+ async function syncTeamConfigAndManifest(dir: string, config: GjcTeamConfig): Promise<void> {
1264
+ await writeJsonFile(path.join(dir, "config.json"), config);
1265
+ await writeJsonFile(manifestPath(dir), manifestRecordFromConfig(config));
1266
+ }
1267
+
1268
+ async function readWorkerMemoryGuardLedger(
1269
+ dir: string,
1270
+ workerId: string,
1271
+ platform: string,
1272
+ ): Promise<GjcTeamWorkerMemoryGuardLedger> {
1273
+ const ledgerPath = workerMemoryGuardLedgerPath(dir, workerId);
1274
+ const existing = await readJsonFile<unknown>(ledgerPath);
1275
+ if (isCanonicalGjcTeamWorkerMemoryGuardLedger(existing) && existing.worker_id === workerId) return existing;
1276
+ const ledger = createInitialGjcTeamWorkerMemoryGuardLedger({ workerId, platform, now: now() });
1277
+ await writeJsonFile(ledgerPath, ledger);
1278
+ return ledger;
1279
+ }
1280
+
1281
+ async function writeWorkerMemoryGuardLedger(
1282
+ dir: string,
1283
+ ledger: GjcTeamWorkerMemoryGuardLedger,
1284
+ ): Promise<GjcTeamWorkerMemoryGuardLedger> {
1285
+ await writeJsonFile(workerMemoryGuardLedgerPath(dir, ledger.worker_id), ledger);
1286
+ return ledger;
1287
+ }
1288
+
1289
+ async function appendWorkerMemoryGuardAction(input: {
1290
+ dir: string;
1291
+ teamName: string;
1292
+ cwd: string;
1293
+ workerId: string;
1294
+ task?: GjcTeamTask;
1295
+ incidentId: string;
1296
+ action: "advisory" | "replace" | "blocked";
1297
+ result: "noop" | "scheduled" | "succeeded" | "failed" | "blocked";
1298
+ reason: string;
1299
+ }): Promise<void> {
1300
+ const workerPath = path.join(input.dir, "workers", safePathSegment("worker_id", input.workerId));
1301
+ const entries = await readTeamWorkerMemoryGuardLedger(workerPath);
1302
+ await appendTeamWorkerMemoryGuardLedgerEntry(
1303
+ workerPath,
1304
+ {
1305
+ schema_version: 1,
1306
+ recorded_at: now(),
1307
+ incident_id: input.incidentId,
1308
+ team_name: input.teamName,
1309
+ worker_id: input.workerId,
1310
+ task_id: input.task?.id ?? "unclaimed",
1311
+ claim_token: input.task?.claim?.token ?? "unclaimed",
1312
+ attempt: nextTeamWorkerMemoryGuardAttempt(entries, input.incidentId),
1313
+ platform: process.platform,
1314
+ action: input.action,
1315
+ result: input.result,
1316
+ reason: input.reason,
1317
+ },
1318
+ { cwd: input.cwd },
1319
+ );
1320
+ }
1321
+
1322
+ function normalizeWorkerMemoryGuardPlatform(value: unknown): string {
1323
+ return typeof value === "string" && value.trim() ? value.trim() : process.platform;
1324
+ }
1325
+
1326
+ async function readLinuxProcessStartTime(pid: number): Promise<string | undefined> {
1327
+ if (process.platform !== "linux" || !Number.isSafeInteger(pid) || pid <= 0) return undefined;
1328
+ try {
1329
+ const stat = await Bun.file(`/proc/${pid}/stat`).text();
1330
+ const commandEnd = stat.lastIndexOf(")");
1331
+ if (commandEnd < 0) return undefined;
1332
+ const fields = stat
1333
+ .slice(commandEnd + 2)
1334
+ .trim()
1335
+ .split(/\s+/);
1336
+ const startTime = fields[19];
1337
+ return startTime && /^\d+$/.test(startTime) ? startTime : undefined;
1338
+ } catch {
1339
+ return undefined;
1340
+ }
1341
+ }
1342
+
1343
+ function normalizeWorkerMemoryGuardSelectionCandidates(
1344
+ value: unknown,
1345
+ ledgers: Map<string, GjcTeamWorkerMemoryGuardLedger>,
1346
+ ): GjcTeamWorkerMemoryGuardSelectionCandidate[] {
1347
+ if (!Array.isArray(value)) return [];
1348
+ const candidates: GjcTeamWorkerMemoryGuardSelectionCandidate[] = [];
1349
+ for (const entry of value) {
1350
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
1351
+ const record = entry as Record<string, unknown>;
1352
+ const workerId =
1353
+ typeof record.worker_id === "string"
1354
+ ? record.worker_id.trim()
1355
+ : typeof record.workerId === "string"
1356
+ ? record.workerId.trim()
1357
+ : "";
1358
+ if (!workerId) continue;
1359
+ const ledger = ledgers.get(workerId);
1360
+ const excessBytes = Number(record.excess_bytes ?? record.excessBytes ?? 0);
1361
+ if (!Number.isFinite(excessBytes)) continue;
1362
+ candidates.push({
1363
+ worker_id: workerId,
1364
+ platform:
1365
+ typeof record.platform === "string" && record.platform.trim()
1366
+ ? record.platform.trim()
1367
+ : (ledger?.platform ?? process.platform),
1368
+ excess_bytes: excessBytes,
1369
+ retry_count:
1370
+ typeof record.retry_count === "number" && Number.isInteger(record.retry_count)
1371
+ ? record.retry_count
1372
+ : (ledger?.retry_count ?? 0),
1373
+ retry_limit:
1374
+ typeof record.retry_limit === "number" && Number.isInteger(record.retry_limit) && record.retry_limit > 0
1375
+ ? record.retry_limit
1376
+ : (ledger?.retry_limit ?? 2),
1377
+ blocked: typeof record.blocked === "boolean" ? record.blocked : ledger?.state === "blocked",
1378
+ current_task_id:
1379
+ typeof record.current_task_id === "string"
1380
+ ? record.current_task_id
1381
+ : typeof record.currentTaskId === "string"
1382
+ ? record.currentTaskId
1383
+ : ledger?.current_task_id,
1384
+ });
1385
+ }
1386
+ return candidates;
1387
+ }
1388
+
1389
+ function withMemoryGuardRetry(
1390
+ ledger: GjcTeamWorkerMemoryGuardLedger,
1391
+ input: {
1392
+ platform: string;
1393
+ reason: string;
1394
+ incidentId: string;
1395
+ currentTaskId?: string;
1396
+ pidProbe?: GjcTeamWorkerMemoryGuardLedger["last_pid_probe"];
1397
+ nowIso: string;
1398
+ },
1399
+ ): { ledger: GjcTeamWorkerMemoryGuardLedger; finalBlocked: boolean } {
1400
+ const retryCount = Math.min(ledger.retry_count + 1, ledger.retry_limit);
1401
+ const finalBlocked = retryCount >= ledger.retry_limit;
1402
+ return {
1403
+ ledger: {
1404
+ ...ledger,
1405
+ platform: input.platform,
1406
+ state: finalBlocked ? "blocked" : "retrying",
1407
+ retry_count: retryCount,
1408
+ current_task_id: input.currentTaskId,
1409
+ last_incident_id: input.incidentId,
1410
+ last_reason: input.reason,
1411
+ last_pid_probe: input.pidProbe,
1412
+ updated_at: input.nowIso,
1413
+ },
1414
+ finalBlocked,
1415
+ };
1416
+ }
1417
+
1418
+ async function finalizeWorkerMemoryGuardBlockedState(input: {
1419
+ teamName: string;
1420
+ dir: string;
1421
+ worker: GjcTeamWorker;
1422
+ task: GjcTeamTask | undefined;
1423
+ taskMutation: GjcTeamTaskMutationCapability;
1424
+ reason: string;
1425
+ cwd: string;
1426
+ env: NodeJS.ProcessEnv;
1427
+ }): Promise<void> {
1428
+ await updateWorkerStatus(
1429
+ workerRuntime,
1430
+ input.teamName,
1431
+ input.worker.id,
1432
+ "blocked",
1433
+ input.cwd,
1434
+ input.env,
1435
+ input.task?.id,
1436
+ input.reason,
1437
+ );
1438
+ await writeLifecycleRecord(workerRuntime, input.dir, input.worker, "failed", { stop_reason: input.reason });
1439
+ const task = input.task;
1440
+ if (!task?.claim) return;
1441
+ await input.taskMutation.transition(task.id, "blocked", task.claim.token, input.worker.id);
1442
+ }
1443
+
1444
+ async function checkpointWorkerForMemoryGuard(
1445
+ worker: GjcTeamWorker,
1446
+ taskId: string | undefined,
1447
+ ): Promise<{ ok: true; checkpoint: GjcTeamWorkerMemoryGuardCheckpoint } | { ok: false; reason: string }> {
1448
+ if (!worker.worktree_path) return { ok: false, reason: "worker_worktree_missing" };
1449
+ let classification: GjcWorkerCheckpointClassification;
1450
+ try {
1451
+ classification = classifyWorkerCheckpointStatus(worker.worktree_path);
1452
+ } catch {
1453
+ return { ok: false, reason: "checkpoint_git_error" };
1454
+ }
1455
+ if (
1456
+ classification.kind !== "clean" &&
1457
+ classification.kind !== "eligible" &&
1458
+ classification.kind !== "protected_only"
1459
+ )
1460
+ return { ok: false, reason: `checkpoint_${classification.kind}` };
1461
+ let commit: string | null = null;
1462
+ if (classification.kind === "eligible") {
1463
+ const added = runGitResult(worker.worktree_path, ["add", "--", ...classification.files]);
1464
+ if (!added.ok) return { ok: false, reason: "checkpoint_git_add_failed" };
1465
+ const message = `gjc(team): memory-guard checkpoint ${worker.id} [${taskId ?? "unknown"}]`;
1466
+ const committed = runGitResult(worker.worktree_path, [
1467
+ "commit",
1468
+ "--no-verify",
1469
+ "--only",
1470
+ "-m",
1471
+ message,
1472
+ "--",
1473
+ ...classification.files,
1474
+ ]);
1475
+ if (!committed.ok) return { ok: false, reason: "checkpoint_git_commit_failed" };
1476
+ commit = resolveHead(worker.worktree_path);
1477
+ }
1478
+ return {
1479
+ ok: true,
1480
+ checkpoint: {
1481
+ kind: classification.kind,
1482
+ files: classification.files,
1483
+ head: resolveHead(worker.worktree_path),
1484
+ commit,
1485
+ recorded_at: now(),
1486
+ },
1487
+ };
1488
+ }
1489
+
1490
+ async function relaunchWorkerPaneForMemoryGuard(input: {
1491
+ config: GjcTeamConfig;
1492
+ worker: GjcTeamWorker;
1493
+ platform: NodeJS.Platform;
1494
+ startupAckPath: string;
1495
+ replacementToken: string;
1496
+ startupAckTimeoutMs: number;
1497
+ }): Promise<string> {
1498
+ if (input.config.dry_run)
1499
+ return `%memory-guard-${input.worker.id}-${stableHash(`${input.worker.id}:${now()}`).slice(0, 8)}`;
1500
+ const workerCommand = buildWorkerCommand(
1501
+ input.config,
1502
+ input.worker,
1503
+ input.platform,
1504
+ `Send startup ACK before resuming: gjc team api worker-startup-ack --input '{"team_name":"${input.config.team_name}","worker_id":"${input.worker.id}","protocol_version":"1","replacement_token":"${input.replacementToken}"}' --json. ${GJC_TEAM_CONTINUATION_PROMPT}`,
1505
+ );
1506
+ const workerCwd = input.worker.worktree_path ?? input.config.leader.cwd;
1507
+ const useSendKeysFallback = shouldDispatchWorkerWithSendKeys(input.config.tmux_command, input.platform);
1508
+ const splitTarget =
1509
+ input.worker.pane_id && paneBelongsToTeamTarget(input.config, input.worker.pane_id)
1510
+ ? input.worker.pane_id
1511
+ : input.config.tmux_target;
1512
+ const split = Bun.spawnSync(
1513
+ [
1514
+ input.config.tmux_command,
1515
+ "split-window",
1516
+ "-v",
1517
+ "-t",
1518
+ splitTarget,
1519
+ "-d",
1520
+ "-P",
1521
+ "-F",
1522
+ "#{pane_id}",
1523
+ "-c",
1524
+ workerCwd,
1525
+ ...(useSendKeysFallback ? [] : [workerCommand]),
1526
+ ],
1527
+ { stdout: "pipe", stderr: "pipe" },
1528
+ );
1529
+ if (split.exitCode !== 0)
1530
+ throw new Error(split.stderr.toString().trim() || `memory_guard_split_failed:${input.worker.id}`);
1531
+ const newPaneId = split.stdout.toString().trim().split(/\r?\n/)[0]?.trim() ?? "";
1532
+ if (!newPaneId.startsWith("%")) throw new Error(`memory_guard_split_missing_pane:${input.worker.id}`);
1533
+ if (useSendKeysFallback) {
1534
+ Bun.spawnSync([input.config.tmux_command, "send-keys", "-l", "-t", newPaneId, workerCommand], {
1535
+ stdout: "ignore",
1536
+ stderr: "ignore",
1537
+ });
1538
+ Bun.spawnSync([input.config.tmux_command, "send-keys", "-t", newPaneId, "Enter"], {
1539
+ stdout: "ignore",
1540
+ stderr: "ignore",
1541
+ });
1542
+ }
1543
+ const startupDeadline = Date.now() + input.startupAckTimeoutMs;
1544
+ while (true) {
1545
+ try {
1546
+ const ack = await Bun.file(input.startupAckPath).json();
1547
+ if (
1548
+ ack &&
1549
+ typeof ack === "object" &&
1550
+ "replacement_token" in ack &&
1551
+ ack.replacement_token === input.replacementToken
1552
+ )
1553
+ break;
1554
+ } catch {
1555
+ // The successor may still be publishing its generation-bound ACK.
1556
+ }
1557
+ if (Date.now() >= startupDeadline) {
1558
+ Bun.spawnSync([input.config.tmux_command, "kill-pane", "-t", newPaneId], {
1559
+ stdout: "ignore",
1560
+ stderr: "ignore",
1561
+ });
1562
+ throw new Error(`memory_guard_successor_startup_timeout:${input.worker.id}`);
1563
+ }
1564
+ await Bun.sleep(50);
1565
+ }
1566
+ const successorPane = probePaneTeamTarget(input.config, newPaneId);
1567
+ if (!successorPane.exists || !successorPane.belongsToTeamTarget) {
1568
+ Bun.spawnSync([input.config.tmux_command, "kill-pane", "-t", newPaneId], {
1569
+ stdout: "ignore",
1570
+ stderr: "ignore",
1571
+ });
1572
+ throw new Error(`memory_guard_successor_pane_unavailable:${input.worker.id}`);
1573
+ }
1574
+ if (input.worker.pane_id) {
1575
+ const oldPane = probePaneTeamTarget(input.config, input.worker.pane_id);
1576
+ if (oldPane.exists && !oldPane.belongsToTeamTarget) {
1577
+ Bun.spawnSync([input.config.tmux_command, "kill-pane", "-t", newPaneId], {
1578
+ stdout: "ignore",
1579
+ stderr: "ignore",
1580
+ });
1581
+ throw new Error(`memory_guard_old_pane_outside_team_target:${input.worker.id}`);
1582
+ }
1583
+ }
1584
+ Bun.spawnSync([input.config.tmux_command, "select-layout", "-t", input.config.tmux_target, "main-vertical"], {
1585
+ stdout: "ignore",
1586
+ stderr: "ignore",
1587
+ });
1588
+ return newPaneId;
1589
+ }
1590
+
1591
+ async function applyWorkerMemoryGuardUnlocked(input: {
1592
+ teamName: string;
1593
+ workerId?: string;
1594
+ requestedWorkerId: string;
1595
+ reason?: string;
1596
+ incidentId?: string;
1597
+ platform: string;
1598
+ pidProbe?: GjcTeamWorkerMemoryGuardLedger["last_pid_probe"];
1599
+ candidates?: unknown;
1600
+ cwd: string;
1601
+ env: NodeJS.ProcessEnv;
1602
+ allowAutomaticAction?: boolean;
1603
+ replacementToken?: string;
1604
+ dir: string;
1605
+ taskMutation: GjcTeamTaskMutationCapability;
1606
+ }): Promise<Record<string, unknown>> {
1607
+ const dir = input.dir;
1608
+ const config = await readConfig(dir);
1609
+ const ledgers = new Map<string, GjcTeamWorkerMemoryGuardLedger>();
1610
+ for (const candidate of config.workers)
1611
+ ledgers.set(candidate.id, await readWorkerMemoryGuardLedger(dir, candidate.id, input.platform));
1612
+ let workerId = input.workerId ?? input.requestedWorkerId;
1613
+ const parsedCandidates = normalizeWorkerMemoryGuardSelectionCandidates(input.candidates, ledgers);
1614
+ if (!input.workerId && Array.isArray(input.candidates)) {
1615
+ const selected = selectGjcTeamWorkerMemoryGuardCandidate(parsedCandidates);
1616
+ if (!selected)
1617
+ return {
1618
+ ok: true,
1619
+ result: "advisory",
1620
+ lifecycle_mutated: false,
1621
+ reason: "no_eligible_worker_memory_guard_candidate",
1622
+ };
1623
+ workerId = selected.worker_id;
1624
+ }
1625
+ const worker = findKnownWorker(config, workerId);
1626
+ const authorityInventory = await readGjcContinuationAuthorityInventory(dir);
1627
+ const authority = authorityInventory.valid
1628
+ ? selectGjcContinuationWorkerAuthority(authorityInventory, worker.id)
1629
+ : { valid: false as const, taskCount: 0, claimCount: 0 };
1630
+ const task = authority.valid ? authority.task : undefined;
1631
+ const leaseExpiresAt = authority.valid ? Date.parse(authority.claim.leased_until) : Number.NaN;
1632
+ let ledger = ledgers.get(worker.id) ?? (await readWorkerMemoryGuardLedger(dir, worker.id, input.platform));
1633
+ const nowIso = now();
1634
+ const currentTaskId = task?.id;
1635
+ const incidentId = input.incidentId ?? stableHash(`${worker.id}:${currentTaskId ?? "none"}:${nowIso}`).slice(0, 16);
1636
+ const baseReason = input.reason?.trim() || "memory_guard_requested";
1637
+ if (!authority.valid || !Number.isFinite(leaseExpiresAt) || leaseExpiresAt <= currentTimeMs()) {
1638
+ const noClaimReason =
1639
+ process.platform !== "linux" || input.platform !== "linux"
1640
+ ? `unsupported_platform:${input.platform}:host:${process.platform}:${baseReason}`
1641
+ : "worker_has_no_exact_active_claim";
1642
+ ledger = {
1643
+ ...ledger,
1644
+ platform: input.platform,
1645
+ state: "advisory",
1646
+ current_task_id: undefined,
1647
+ last_incident_id: incidentId,
1648
+ last_reason: noClaimReason,
1649
+ last_pid_probe: input.pidProbe,
1650
+ updated_at: nowIso,
1651
+ };
1652
+ await writeWorkerMemoryGuardLedger(dir, ledger);
1653
+ await appendWorkerMemoryGuardAction({
1654
+ dir,
1655
+ teamName: input.teamName,
1656
+ cwd: input.cwd,
1657
+ workerId: worker.id,
1658
+ task,
1659
+ incidentId,
1660
+ action: "advisory",
1661
+ result: "noop",
1662
+ reason: noClaimReason,
1663
+ });
1664
+ return {
1665
+ ok: true,
1666
+ result: "advisory",
1667
+ lifecycle_mutated: false,
1668
+ ledger,
1669
+ reason: noClaimReason,
1670
+ };
1671
+ }
1672
+ if (ledger.state === "blocked" || ledger.retry_count >= ledger.retry_limit) {
1673
+ await appendWorkerMemoryGuardAction({
1674
+ dir,
1675
+ teamName: input.teamName,
1676
+ cwd: input.cwd,
1677
+ workerId: worker.id,
1678
+ task,
1679
+ incidentId,
1680
+ action: "blocked",
1681
+ result: "blocked",
1682
+ reason: ledger.last_reason ?? "retry_limit",
1683
+ });
1684
+ return { ok: true, result: "blocked", lifecycle_mutated: false, ledger };
1685
+ }
1686
+ if (input.allowAutomaticAction !== undefined)
1687
+ ledger = {
1688
+ ...ledger,
1689
+ automatic_action_allowed: input.allowAutomaticAction,
1690
+ updated_at: nowIso,
1691
+ };
1692
+ if (process.platform !== "linux" || input.platform !== "linux") {
1693
+ ledger = {
1694
+ ...ledger,
1695
+ platform: input.platform,
1696
+ state: "advisory",
1697
+ current_task_id: currentTaskId,
1698
+ last_incident_id: incidentId,
1699
+ last_reason: `unsupported_platform:${input.platform}:host:${process.platform}:${baseReason}`,
1700
+ last_pid_probe: input.pidProbe,
1701
+ updated_at: nowIso,
1702
+ };
1703
+ await writeWorkerMemoryGuardLedger(dir, ledger);
1704
+ await appendWorkerMemoryGuardAction({
1705
+ dir,
1706
+ teamName: input.teamName,
1707
+ cwd: input.cwd,
1708
+ workerId: worker.id,
1709
+ task,
1710
+ incidentId,
1711
+ action: "advisory",
1712
+ result: "noop",
1713
+ reason: ledger.last_reason ?? "unsupported_platform",
1714
+ });
1715
+ return { ok: true, result: "advisory", lifecycle_mutated: false, ledger };
1716
+ }
1717
+ if (!ledger.automatic_action_allowed) {
1718
+ ledger = {
1719
+ ...ledger,
1720
+ platform: input.platform,
1721
+ state: "advisory",
1722
+ current_task_id: currentTaskId,
1723
+ last_incident_id: incidentId,
1724
+ last_reason: "automatic_linux_action_disabled",
1725
+ last_pid_probe: input.pidProbe,
1726
+ updated_at: nowIso,
1727
+ };
1728
+ await writeWorkerMemoryGuardLedger(dir, ledger);
1729
+ await appendWorkerMemoryGuardAction({
1730
+ dir,
1731
+ teamName: input.teamName,
1732
+ cwd: input.cwd,
1733
+ workerId: worker.id,
1734
+ task,
1735
+ incidentId,
1736
+ action: "advisory",
1737
+ result: "noop",
1738
+ reason: "automatic_linux_action_disabled",
1739
+ });
1740
+ return { ok: true, result: "advisory", lifecycle_mutated: false, ledger };
1741
+ }
1742
+ const controllerPath = path.join(dir, "memory-guard-controller.json");
1743
+ const controllerProcessStart = await readLinuxProcessStartTime(process.pid);
1744
+ const controllerId = `pid:${process.pid}:start:${controllerProcessStart ?? "unknown"}`;
1745
+ const controllerNow = currentTimeMs();
1746
+ const controllerCooldownMs = parseDurationEnv(input.env, "GJC_TEAM_MEMORY_GUARD_ACTION_COOLDOWN_MS", 120_000);
1747
+ const existingController = await readJsonFile<{
1748
+ controller_id?: string;
1749
+ cooldown_until?: string;
1750
+ }>(controllerPath);
1751
+ const existingCooldownUntil = Date.parse(existingController?.cooldown_until ?? "");
1752
+ if (
1753
+ existingController?.controller_id &&
1754
+ existingController.controller_id !== controllerId &&
1755
+ Number.isFinite(existingCooldownUntil) &&
1756
+ existingCooldownUntil > controllerNow
1757
+ )
1758
+ return {
1759
+ ok: true,
1760
+ result: "advisory",
1761
+ lifecycle_mutated: false,
1762
+ reason: "team_memory_guard_controller_active",
1763
+ };
1764
+ await writeJsonFile(controllerPath, {
1765
+ schema_version: 1,
1766
+ controller_id: controllerId,
1767
+ worker_id: worker.id,
1768
+ incident_id: incidentId,
1769
+ reserved_at: new Date(controllerNow).toISOString(),
1770
+ cooldown_until: new Date(controllerNow + controllerCooldownMs).toISOString(),
1771
+ });
1772
+ const checkpoint = await checkpointWorkerForMemoryGuard(worker, currentTaskId);
1773
+ if (!checkpoint.ok) {
1774
+ const retried = withMemoryGuardRetry(ledger, {
1775
+ platform: input.platform,
1776
+ reason: checkpoint.reason,
1777
+ incidentId,
1778
+ currentTaskId,
1779
+ pidProbe: input.pidProbe,
1780
+ nowIso,
1781
+ });
1782
+ ledger = retried.ledger;
1783
+ await writeWorkerMemoryGuardLedger(dir, ledger);
1784
+ if (retried.finalBlocked)
1785
+ await finalizeWorkerMemoryGuardBlockedState({
1786
+ teamName: input.teamName,
1787
+ dir,
1788
+ worker,
1789
+ task,
1790
+ taskMutation: input.taskMutation,
1791
+ reason: checkpoint.reason,
1792
+ cwd: input.cwd,
1793
+ env: input.env,
1794
+ });
1795
+ await appendWorkerMemoryGuardAction({
1796
+ dir,
1797
+ teamName: input.teamName,
1798
+ cwd: input.cwd,
1799
+ workerId: worker.id,
1800
+ task,
1801
+ incidentId,
1802
+ action: retried.finalBlocked ? "blocked" : "replace",
1803
+ result: retried.finalBlocked ? "blocked" : "failed",
1804
+ reason: checkpoint.reason,
1805
+ });
1806
+ return {
1807
+ ok: true,
1808
+ result: retried.finalBlocked ? "blocked" : "retrying",
1809
+ lifecycle_mutated: retried.finalBlocked,
1810
+ ledger,
1811
+ };
1812
+ }
1813
+ const refreshedInventory = await readGjcContinuationAuthorityInventory(dir);
1814
+ const refreshedAuthority = refreshedInventory.valid
1815
+ ? selectGjcContinuationWorkerAuthority(refreshedInventory, worker.id)
1816
+ : { valid: false as const, taskCount: 0, claimCount: 0 };
1817
+ if (
1818
+ !refreshedAuthority.valid ||
1819
+ refreshedAuthority.task.id !== task?.id ||
1820
+ refreshedAuthority.claim.token !== task?.claim?.token ||
1821
+ refreshedAuthority.claim.leased_until !== task?.claim?.leased_until ||
1822
+ Date.parse(refreshedAuthority.claim.leased_until) <= currentTimeMs()
1823
+ )
1824
+ return {
1825
+ ok: true,
1826
+ result: "advisory",
1827
+ lifecycle_mutated: false,
1828
+ reason: "worker_claim_authority_changed",
1829
+ };
1830
+ const startupAckPath = path.join(dir, "workers", safePathSegment("worker_id", worker.id), "startup-ack.json");
1831
+ const previousStartupAck = (await Bun.file(startupAckPath).exists())
1832
+ ? await Bun.file(startupAckPath).text()
1833
+ : undefined;
1834
+ const lifecyclePath = workerLifecyclePath(dir, worker.id);
1835
+ const previousLifecycle = (await Bun.file(lifecyclePath).exists())
1836
+ ? await Bun.file(lifecyclePath).text()
1837
+ : undefined;
1838
+ const restorePredecessorStartupState = async (): Promise<void> => {
1839
+ if (previousLifecycle === undefined) await fs.rm(lifecyclePath, { force: true });
1840
+ else await Bun.write(lifecyclePath, previousLifecycle);
1841
+ if (previousStartupAck === undefined) await fs.rm(startupAckPath, { force: true });
1842
+ else await Bun.write(startupAckPath, previousStartupAck);
1843
+ };
1844
+ await fs.rm(startupAckPath, { force: true });
1845
+ const replacementToken = input.replacementToken ?? randomUUID();
1846
+ const startupAckTimeoutMs = parseDurationEnv(input.env, "GJC_TEAM_MEMORY_GUARD_STARTUP_TIMEOUT_MS", 120_000);
1847
+ let newPaneId: string;
1848
+ try {
1849
+ newPaneId = await relaunchWorkerPaneForMemoryGuard({
1850
+ config,
1851
+ worker,
1852
+ platform: process.platform,
1853
+ startupAckPath,
1854
+ replacementToken,
1855
+ startupAckTimeoutMs,
1856
+ });
1857
+ } catch (error) {
1858
+ await restorePredecessorStartupState();
1859
+ const reason = error instanceof Error && error.message ? `relaunch_failed:${error.message}` : "relaunch_failed";
1860
+ const retried = withMemoryGuardRetry(ledger, {
1861
+ platform: input.platform,
1862
+ reason,
1863
+ incidentId,
1864
+ currentTaskId,
1865
+ pidProbe: input.pidProbe,
1866
+ nowIso,
1867
+ });
1868
+ ledger = retried.ledger;
1869
+ await writeWorkerMemoryGuardLedger(dir, ledger);
1870
+ if (retried.finalBlocked)
1871
+ await finalizeWorkerMemoryGuardBlockedState({
1872
+ teamName: input.teamName,
1873
+ dir,
1874
+ worker,
1875
+ task,
1876
+ taskMutation: input.taskMutation,
1877
+ reason,
1878
+ cwd: input.cwd,
1879
+ env: input.env,
1880
+ });
1881
+ await appendWorkerMemoryGuardAction({
1882
+ dir,
1883
+ teamName: input.teamName,
1884
+ cwd: input.cwd,
1885
+ workerId: worker.id,
1886
+ task,
1887
+ incidentId,
1888
+ action: retried.finalBlocked ? "blocked" : "replace",
1889
+ result: retried.finalBlocked ? "blocked" : "failed",
1890
+ reason,
1891
+ });
1892
+ return {
1893
+ ok: true,
1894
+ result: retried.finalBlocked ? "blocked" : "retrying",
1895
+ lifecycle_mutated: retried.finalBlocked,
1896
+ ledger,
1897
+ };
1898
+ }
1899
+ const postAckInventory = await readGjcContinuationAuthorityInventory(dir);
1900
+ const postAckAuthority = postAckInventory.valid
1901
+ ? selectGjcContinuationWorkerAuthority(postAckInventory, worker.id)
1902
+ : { valid: false as const, taskCount: 0, claimCount: 0 };
1903
+ if (
1904
+ !postAckAuthority.valid ||
1905
+ postAckAuthority.task.id !== task?.id ||
1906
+ postAckAuthority.claim.token !== task?.claim?.token ||
1907
+ postAckAuthority.claim.leased_until !== task?.claim?.leased_until ||
1908
+ Date.parse(postAckAuthority.claim.leased_until) <= currentTimeMs()
1909
+ ) {
1910
+ Bun.spawnSync([config.tmux_command, "kill-pane", "-t", newPaneId], {
1911
+ stdout: "ignore",
1912
+ stderr: "ignore",
1913
+ });
1914
+ await restorePredecessorStartupState();
1915
+ return {
1916
+ ok: true,
1917
+ result: "advisory",
1918
+ lifecycle_mutated: false,
1919
+ reason: "worker_claim_authority_changed_after_startup",
1920
+ };
1921
+ }
1922
+ const successorPane = probePaneTeamTarget(config, newPaneId);
1923
+ if (!successorPane.exists || !successorPane.belongsToTeamTarget || !successorPane.pid) {
1924
+ Bun.spawnSync([config.tmux_command, "kill-pane", "-t", newPaneId], {
1925
+ stdout: "ignore",
1926
+ stderr: "ignore",
1927
+ });
1928
+ await restorePredecessorStartupState();
1929
+ return {
1930
+ ok: true,
1931
+ result: "advisory",
1932
+ lifecycle_mutated: false,
1933
+ reason: "successor_pane_unavailable_after_startup",
1934
+ };
1935
+ }
1936
+ const heartbeatPath = path.join(dir, "workers", safePathSegment("worker_id", worker.id), "heartbeat.json");
1937
+ const previousHeartbeat = (await Bun.file(heartbeatPath).exists())
1938
+ ? await Bun.file(heartbeatPath).text()
1939
+ : undefined;
1940
+ const successorHeartbeat: WorkerHeartbeatFile = {
1941
+ pid: successorPane.pid,
1942
+ last_turn_at: now(),
1943
+ turn_count: 0,
1944
+ alive: true,
1945
+ process_start_time: await readLinuxProcessStartTime(successorPane.pid),
1946
+ };
1947
+ const nextConfig: GjcTeamConfig = {
1948
+ ...config,
1949
+ workers: config.workers.map(candidate =>
1950
+ candidate.id === worker.id
1951
+ ? { ...candidate, pane_id: newPaneId, last_heartbeat: nowIso, status: "idle" }
1952
+ : candidate,
1953
+ ),
1954
+ updated_at: nowIso,
1955
+ };
1956
+ const rollbackReplacement = async (): Promise<void> => {
1957
+ Bun.spawnSync([config.tmux_command, "kill-pane", "-t", newPaneId], {
1958
+ stdout: "ignore",
1959
+ stderr: "ignore",
1960
+ });
1961
+ if (previousHeartbeat === undefined) await fs.rm(heartbeatPath, { force: true });
1962
+ else await Bun.write(heartbeatPath, previousHeartbeat);
1963
+ await syncTeamConfigAndManifest(dir, config);
1964
+ await restorePredecessorStartupState();
1965
+ };
1966
+ try {
1967
+ await writeJsonFile(heartbeatPath, successorHeartbeat);
1968
+ await syncTeamConfigAndManifest(dir, nextConfig);
1969
+ await writeLifecycleRecord(workerRuntime, dir, { ...worker, pane_id: newPaneId }, "ready", {
1970
+ pane_id: newPaneId,
1971
+ started_at: nowIso,
1972
+ stop_reason: undefined,
1973
+ stopped_at: undefined,
1974
+ });
1975
+ const cutoverPane = probePaneTeamTarget(config, newPaneId);
1976
+ if (!cutoverPane.exists || !cutoverPane.belongsToTeamTarget || cutoverPane.pid !== successorHeartbeat.pid)
1977
+ throw new Error(`memory_guard_successor_pane_changed:${worker.id}`);
1978
+ if (worker.pane_id && !config.dry_run) {
1979
+ const killed = Bun.spawnSync([config.tmux_command, "kill-pane", "-t", worker.pane_id], {
1980
+ stdout: "ignore",
1981
+ stderr: "pipe",
1982
+ });
1983
+ if (killed.exitCode !== 0 && probePaneTeamTarget(config, worker.pane_id).exists)
1984
+ throw new Error(killed.stderr.toString().trim() || `memory_guard_kill_failed:${worker.id}`);
1985
+ }
1986
+ } catch (error) {
1987
+ await rollbackReplacement();
1988
+ throw new Error(
1989
+ error instanceof Error && error.message
1990
+ ? `memory_guard_replacement_commit_failed:${error.message}`
1991
+ : "memory_guard_replacement_commit_failed",
1992
+ );
1993
+ }
1994
+ ledger = {
1995
+ ...ledger,
1996
+ platform: input.platform,
1997
+ state: "replaced",
1998
+ retry_count: 0,
1999
+ current_task_id: currentTaskId,
2000
+ last_incident_id: incidentId,
2001
+ last_reason: baseReason,
2002
+ last_pid_probe: input.pidProbe,
2003
+ last_checkpoint: checkpoint.checkpoint,
2004
+ last_replacement: {
2005
+ old_pane_id: worker.pane_id,
2006
+ new_pane_id: newPaneId,
2007
+ recorded_at: nowIso,
2008
+ },
2009
+ updated_at: nowIso,
2010
+ };
2011
+ await writeWorkerMemoryGuardLedger(dir, ledger);
2012
+ await appendWorkerMemoryGuardAction({
2013
+ dir,
2014
+ teamName: input.teamName,
2015
+ cwd: input.cwd,
2016
+ workerId: worker.id,
2017
+ task,
2018
+ incidentId,
2019
+ action: "replace",
2020
+ result: "succeeded",
2021
+ reason: baseReason,
2022
+ });
2023
+ await appendEvent(dir, {
2024
+ type: "worker_memory_guard_replaced",
2025
+ worker: worker.id,
2026
+ task_id: currentTaskId,
2027
+ message: `Replaced ${worker.id} after memory-guard checkpoint`,
2028
+ data: {
2029
+ incident_id: incidentId,
2030
+ checkpoint_kind: checkpoint.checkpoint.kind,
2031
+ checkpoint_commit: checkpoint.checkpoint.commit,
2032
+ old_pane_id: worker.pane_id,
2033
+ new_pane_id: newPaneId,
2034
+ },
2035
+ });
2036
+ return {
2037
+ ok: true,
2038
+ result: "replaced",
2039
+ lifecycle_mutated: true,
2040
+ ledger,
2041
+ checkpoint: checkpoint.checkpoint,
2042
+ };
2043
+ }
2044
+
2045
+ async function applyWorkerMemoryGuard(
2046
+ input: Omit<Parameters<typeof applyWorkerMemoryGuardUnlocked>[0], "dir" | "taskMutation">,
2047
+ ): Promise<Record<string, unknown>> {
2048
+ const dir = await findTeamDir(input.teamName, input.cwd, input.env);
2049
+ return withGjcTeamTaskMutation(taskStore(dir), taskMutation =>
2050
+ applyWorkerMemoryGuardUnlocked({ ...input, dir, taskMutation }),
2051
+ );
2052
+ }
1221
2053
  async function readPhase(dir: string): Promise<GjcTeamPhase> {
1222
2054
  try {
1223
2055
  const phase = await readJsonFile<{ current_phase?: GjcTeamPhase }>(path.join(dir, "phase.json"));
@@ -1998,15 +2830,27 @@ async function startTmuxSession(
1998
2830
  throw error;
1999
2831
  }
2000
2832
  }
2833
+ function probePaneTeamTarget(
2834
+ config: GjcTeamConfig,
2835
+ paneId: string,
2836
+ ): { exists: boolean; belongsToTeamTarget: boolean; pid?: number } {
2837
+ if (paneId === config.leader.pane_id) return { exists: true, belongsToTeamTarget: false };
2838
+ const result = Bun.spawnSync(
2839
+ [config.tmux_command, "display-message", "-p", "-t", paneId, "#S:#I #{pane_id} #{pane_pid}"],
2840
+ { stdout: "pipe", stderr: "ignore" },
2841
+ );
2842
+ if (result.exitCode !== 0) return { exists: false, belongsToTeamTarget: false };
2843
+ const [target = "", detectedPaneId = "", rawPid = ""] = result.stdout.toString().trim().split(/\s+/);
2844
+ const pid = Number(rawPid);
2845
+ return {
2846
+ exists: true,
2847
+ belongsToTeamTarget: target === config.tmux_target && detectedPaneId === paneId,
2848
+ pid: Number.isInteger(pid) && pid > 0 ? pid : undefined,
2849
+ };
2850
+ }
2851
+
2001
2852
  function paneBelongsToTeamTarget(config: GjcTeamConfig, paneId: string): boolean {
2002
- if (paneId === config.leader.pane_id) return false;
2003
- const result = Bun.spawnSync([config.tmux_command, "display-message", "-p", "-t", paneId, "#S:#I #{pane_id}"], {
2004
- stdout: "pipe",
2005
- stderr: "ignore",
2006
- });
2007
- if (result.exitCode !== 0) return false;
2008
- const [target = "", detectedPaneId = ""] = result.stdout.toString().trim().split(/\s+/);
2009
- return target === config.tmux_target && detectedPaneId === paneId;
2853
+ return probePaneTeamTarget(config, paneId).belongsToTeamTarget;
2010
2854
  }
2011
2855
  function killWorkerPanes(config: GjcTeamConfig): void {
2012
2856
  for (const worker of config.workers)
@@ -2147,13 +2991,24 @@ const UNMERGED_GIT_STATUS_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "
2147
2991
  // into the leader branch on projects that do not gitignore `.gjc/_session-*/`.
2148
2992
  const PROTECTED_WORKER_CHECKPOINT_PREFIXES = [".gjc/_session-*/"];
2149
2993
 
2150
- function parsePorcelainStatusFiles(stdout: string): string[] {
2151
- return stdout
2152
- .split(/\r?\n/)
2153
- .map(line => line.trimEnd())
2154
- .filter(Boolean)
2155
- .map(line => line.slice(3).trim())
2156
- .filter(Boolean);
2994
+ function parsePorcelainStatus(stdout: string): { files: string[]; statusCodes: string[] } {
2995
+ const records = stdout.split("\0");
2996
+ const files: string[] = [];
2997
+ const statusCodes: string[] = [];
2998
+ for (let index = 0; index < records.length; index++) {
2999
+ const record = records[index];
3000
+ if (!record) continue;
3001
+ const statusCode = record.slice(0, 2);
3002
+ const file = record.slice(3);
3003
+ if (!file) continue;
3004
+ statusCodes.push(statusCode);
3005
+ files.push(file);
3006
+ if (statusCode.includes("R") || statusCode.includes("C")) {
3007
+ const source = records[++index];
3008
+ if (source) files.push(source);
3009
+ }
3010
+ }
3011
+ return { files: [...new Set(files)], statusCodes };
2157
3012
  }
2158
3013
 
2159
3014
  function normalizeGitStatusPath(filePath: string): string {
@@ -2180,7 +3035,7 @@ export function classifyGjcTeamCheckpointFiles(files: string[]): {
2180
3035
  }
2181
3036
 
2182
3037
  export function classifyWorkerCheckpointStatus(cwd: string): GjcWorkerCheckpointClassification {
2183
- const status = runGitResult(cwd, ["status", "--porcelain", "-uall"]);
3038
+ const status = runGitResult(cwd, ["status", "--porcelain=v1", "-z", "-uall"]);
2184
3039
  if (!status.ok) {
2185
3040
  return {
2186
3041
  kind: "git_error",
@@ -2189,11 +3044,9 @@ export function classifyWorkerCheckpointStatus(cwd: string): GjcWorkerCheckpoint
2189
3044
  };
2190
3045
  }
2191
3046
  if (!status.stdout.trim()) return { kind: "clean", files: [] };
2192
- const files = parsePorcelainStatusFiles(status.stdout);
2193
- const hasUnmergedStatus = status.stdout
2194
- .split(/\r?\n/)
2195
- .filter(Boolean)
2196
- .some(line => UNMERGED_GIT_STATUS_CODES.has(line.slice(0, 2)));
3047
+ const parsed = parsePorcelainStatus(status.stdout);
3048
+ const files = parsed.files;
3049
+ const hasUnmergedStatus = parsed.statusCodes.some(code => UNMERGED_GIT_STATUS_CODES.has(code));
2197
3050
  const conflictFiles = listConflictFiles(cwd);
2198
3051
  if (hasUnmergedStatus || conflictFiles.length > 0) {
2199
3052
  return {
@@ -2210,7 +3063,7 @@ export async function classifyWorkerCheckpointStatusAsync(
2210
3063
  cwd: string,
2211
3064
  signal?: AbortSignal,
2212
3065
  ): Promise<GjcWorkerCheckpointClassification> {
2213
- const status = await runGitResultAsync(cwd, ["status", "--porcelain", "-uall"], signal);
3066
+ const status = await runGitResultAsync(cwd, ["status", "--porcelain=v1", "-z", "-uall"], signal);
2214
3067
  if (!status.ok) {
2215
3068
  return {
2216
3069
  kind: "git_error",
@@ -2219,11 +3072,9 @@ export async function classifyWorkerCheckpointStatusAsync(
2219
3072
  };
2220
3073
  }
2221
3074
  if (!status.stdout.trim()) return { kind: "clean", files: [] };
2222
- const files = parsePorcelainStatusFiles(status.stdout);
2223
- const hasUnmergedStatus = status.stdout
2224
- .split(/\r?\n/)
2225
- .filter(Boolean)
2226
- .some(line => UNMERGED_GIT_STATUS_CODES.has(line.slice(0, 2)));
3075
+ const parsed = parsePorcelainStatus(status.stdout);
3076
+ const files = parsed.files;
3077
+ const hasUnmergedStatus = parsed.statusCodes.some(code => UNMERGED_GIT_STATUS_CODES.has(code));
2227
3078
  const conflictFiles = await listConflictFilesAsync(cwd, signal);
2228
3079
  if (hasUnmergedStatus || conflictFiles.length > 0) {
2229
3080
  return {
@@ -3387,8 +4238,7 @@ async function writeGjcWorkerStartupAck(
3387
4238
  env: NodeJS.ProcessEnv,
3388
4239
  input: Record<string, unknown>,
3389
4240
  ): Promise<Record<string, unknown>> {
3390
- const dir = await findTeamDir(teamName, cwd, env);
3391
- return withGjcTeamMutationFence(dir, () => writeWorkerStartupAck(workerRuntime, teamName, worker, cwd, env, input));
4241
+ return writeWorkerStartupAck(workerRuntime, teamName, worker, cwd, env, input);
3392
4242
  }
3393
4243
  function parseDurationEnv(env: NodeJS.ProcessEnv, name: string, fallbackMs: number): number {
3394
4244
  const raw = env[name]?.trim();
@@ -4248,19 +5098,106 @@ export async function executeGjcTeamApiOperation(
4248
5098
  return readGjcWorkerHeartbeat(teamName, worker, cwd, env);
4249
5099
  case "recover-stale-claims":
4250
5100
  return recoverGjcTeamStaleClaims(teamName, cwd, env);
4251
- case "update-worker-heartbeat":
5101
+ case "update-worker-heartbeat": {
5102
+ const pid = Number(input.pid ?? 0);
4252
5103
  return updateGjcWorkerHeartbeat(
4253
5104
  teamName,
4254
5105
  worker,
4255
5106
  {
4256
- pid: Number(input.pid ?? 0),
5107
+ pid,
4257
5108
  last_turn_at: now(),
4258
5109
  turn_count: Number(input.turn_count ?? 0),
4259
5110
  alive: Boolean(input.alive ?? true),
5111
+ process_start_time: await readLinuxProcessStartTime(pid),
4260
5112
  },
4261
5113
  cwd,
4262
5114
  env,
4263
5115
  );
5116
+ }
5117
+ case "read-worker-memory-guard": {
5118
+ const dir = await findTeamDir(teamName, cwd, env);
5119
+ const config = await readConfig(dir);
5120
+ assertKnownWorker(config, worker);
5121
+ return readWorkerMemoryGuardLedger(dir, worker, normalizeWorkerMemoryGuardPlatform(input.platform));
5122
+ }
5123
+ case "update-worker-memory-guard": {
5124
+ const dir = await findTeamDir(teamName, cwd, env);
5125
+ return withGjcTeamMutationFence(dir, async () => {
5126
+ const config = await readConfig(dir);
5127
+ assertKnownWorker(config, worker);
5128
+ const platform = normalizeWorkerMemoryGuardPlatform(input.platform);
5129
+ const tasks = await readTasks(dir);
5130
+ const currentTaskIdInput = input.current_task_id ?? input.currentTaskId;
5131
+ if (typeof currentTaskIdInput === "string" && !currentTaskIdInput.trim())
5132
+ throw new Error("invalid_worker_memory_guard_task_id");
5133
+ const currentTaskId =
5134
+ typeof currentTaskIdInput === "string"
5135
+ ? currentTaskIdInput.trim()
5136
+ : findGjcTeamClaimedTaskForWorker(tasks, worker)?.id;
5137
+ const existing = await readWorkerMemoryGuardLedger(dir, worker, platform);
5138
+ const retryLimitInput = Number(input.retry_limit ?? input.retryLimit ?? existing.retry_limit);
5139
+ if (!Number.isInteger(retryLimitInput) || retryLimitInput <= 0)
5140
+ throw new Error(`invalid_worker_memory_guard_retry_limit:${retryLimitInput}`);
5141
+ const pidProbe = normalizeGjcTeamWorkerMemoryGuardPidProbe(input.pid_probe ?? input.pidProbe);
5142
+ const stateInput = typeof input.state === "string" ? input.state.trim() : existing.state;
5143
+ if (!["idle", "advisory", "retrying", "checkpointed", "replaced", "blocked"].includes(stateInput))
5144
+ throw new Error(`invalid_worker_memory_guard_state:${stateInput}`);
5145
+ const updated: GjcTeamWorkerMemoryGuardLedger = {
5146
+ ...existing,
5147
+ platform,
5148
+ state: stateInput as GjcTeamWorkerMemoryGuardLedger["state"],
5149
+ automatic_action_allowed:
5150
+ typeof input.automatic_action_allowed === "boolean"
5151
+ ? input.automatic_action_allowed
5152
+ : typeof input.automaticActionAllowed === "boolean"
5153
+ ? input.automaticActionAllowed
5154
+ : existing.automatic_action_allowed,
5155
+ retry_limit: retryLimitInput,
5156
+ current_task_id: currentTaskId,
5157
+ last_incident_id:
5158
+ typeof input.incident_id === "string"
5159
+ ? input.incident_id
5160
+ : typeof input.incidentId === "string"
5161
+ ? input.incidentId
5162
+ : existing.last_incident_id,
5163
+ last_reason:
5164
+ typeof input.reason === "string" && input.reason.trim() ? input.reason.trim() : existing.last_reason,
5165
+ last_pid_probe: pidProbe ?? existing.last_pid_probe,
5166
+ updated_at: now(),
5167
+ };
5168
+ return writeWorkerMemoryGuardLedger(dir, updated);
5169
+ });
5170
+ }
5171
+ case "apply-worker-memory-guard":
5172
+ return applyWorkerMemoryGuard({
5173
+ teamName,
5174
+ workerId: explicitWorker,
5175
+ requestedWorkerId: worker,
5176
+ reason: typeof input.reason === "string" ? input.reason : undefined,
5177
+ incidentId:
5178
+ typeof input.incident_id === "string"
5179
+ ? input.incident_id
5180
+ : typeof input.incidentId === "string"
5181
+ ? input.incidentId
5182
+ : undefined,
5183
+ platform: normalizeWorkerMemoryGuardPlatform(input.platform),
5184
+ pidProbe: normalizeGjcTeamWorkerMemoryGuardPidProbe(input.pid_probe ?? input.pidProbe),
5185
+ candidates: input.candidates,
5186
+ cwd,
5187
+ env,
5188
+ allowAutomaticAction:
5189
+ typeof input.automatic_action_allowed === "boolean"
5190
+ ? input.automatic_action_allowed
5191
+ : typeof input.automaticActionAllowed === "boolean"
5192
+ ? input.automaticActionAllowed
5193
+ : undefined,
5194
+ replacementToken:
5195
+ typeof input.replacement_token === "string"
5196
+ ? input.replacement_token
5197
+ : typeof input.replacementToken === "string"
5198
+ ? input.replacementToken
5199
+ : undefined,
5200
+ });
4264
5201
  case "write-worker-inbox":
4265
5202
  return writeGjcWorkerInbox(teamName, worker, String(input.content ?? ""), cwd, env);
4266
5203
  case "write-worker-identity":