fuli-context 0.7.5 → 0.7.7

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 (148) hide show
  1. package/README.md +69 -13
  2. package/README.zh-CN.md +60 -14
  3. package/dist/web/assets/{AboutPage-ByCUor-j.js → AboutPage-BcZeFcHe.js} +1 -1
  4. package/dist/web/assets/{ConnectionsPage-DkdkZ40Q.js → ConnectionsPage-xAQYl1SQ.js} +1 -1
  5. package/dist/web/assets/EmployeeRecruitDialog-CbPAmhy7.js +1 -0
  6. package/dist/web/assets/EmployeeRecruitDialog-CfFlyKSh.css +1 -0
  7. package/dist/web/assets/EmployeeWorkbenchPage-BQvTLoc8.css +1 -0
  8. package/dist/web/assets/EmployeeWorkbenchPage-DF3EL0Ui.js +1 -0
  9. package/dist/web/assets/KnowledgeEditDialog-UnnBDt0I.js +1 -0
  10. package/dist/web/assets/{KnowledgeEditDialog-DWUV_NPp.css → KnowledgeEditDialog-iv-dgUeD.css} +1 -1
  11. package/dist/web/assets/{KnowledgeInspector-CYAjAb9J.js → KnowledgeInspector-BqPJH5Rr.js} +1 -1
  12. package/dist/web/assets/{KnowledgeOrganizerPage-CV4F54R_.js → KnowledgeOrganizerPage-CyvRflXM.js} +1 -1
  13. package/dist/web/assets/KnowledgePage-59eczTqe.js +1 -0
  14. package/dist/web/assets/{KnowledgeWorkspace-z4u5mXOG.js → KnowledgeWorkspace-CfIjhOog.js} +1 -1
  15. package/dist/web/assets/{OverviewPage-BWOfeS6k.js → OverviewPage-Xyfm5xkZ.js} +1 -1
  16. package/dist/web/assets/{PersonalProfilePage-c3w4-h7a.js → PersonalProfilePage-DB9mByB3.js} +1 -1
  17. package/dist/web/assets/PersonalProjectsPage-_-YXVi5O.js +1 -0
  18. package/dist/web/assets/ProjectAgentsPage-C3vW6wmk.css +1 -0
  19. package/dist/web/assets/ProjectAgentsPage-DeeQByPX.js +11 -0
  20. package/dist/web/assets/{PublicProjectsPage-DdH0oGB3.js → PublicProjectsPage-BEZ1YPs3.js} +1 -1
  21. package/dist/web/assets/{ReviewPage-BYIpcIHG.js → ReviewPage-CQYGwFBH.js} +1 -1
  22. package/dist/web/assets/SearchableSelect-D27H0MoP.js +1 -0
  23. package/dist/web/assets/{SettingsPage-2VkbXgZi.js → SettingsPage-BlEZ03Yi.js} +1 -1
  24. package/dist/web/assets/{WritingTastePage-BOPQZuva.js → WritingTastePage-DvRt_nmP.js} +1 -1
  25. package/dist/web/assets/catalog-DFvIaHVS.js +1 -0
  26. package/dist/web/assets/{console-BlAhujsH.js → console-tOxOl8Vq.js} +1 -1
  27. package/dist/web/assets/conversation-launcher-settings-DuFSpPAo.js +1 -0
  28. package/dist/web/assets/{i18n-Ci3heuiL.js → i18n-CgBHZthI.js} +3 -3
  29. package/dist/web/assets/{identity-4_-v6cB7.js → identity-CFo_Uw1H.js} +1 -1
  30. package/dist/web/assets/index-CJc5ppXH.css +1 -0
  31. package/dist/web/assets/index-yCi3cz2l.js +2 -0
  32. package/dist/web/assets/{model-yG0YqUyZ.js → model-BtnVr7fX.js} +1 -1
  33. package/dist/web/assets/{useApi-CROJJdhE-twhdZArA.js → useApi-CROJJdhE-B6MHm_Av.js} +1 -1
  34. package/dist/web/assets/{vue-router-C-wot_Di.js → vue-router-CzHm-y-x.js} +1 -1
  35. package/dist/web/index.html +7 -6
  36. package/docs/employee-agents.md +99 -0
  37. package/docs/project-agent-memory.md +479 -0
  38. package/graph-provider/fuli_graph/knowledge_audit_models.py +171 -0
  39. package/graph-provider/fuli_graph/model_base.py +5 -0
  40. package/graph-provider/fuli_graph/model_validation.py +32 -0
  41. package/graph-provider/fuli_graph/models.py +26 -170
  42. package/graph-provider/fuli_graph/project_agent_context_models.py +29 -0
  43. package/graph-provider/fuli_graph/project_agent_coordination_models.py +22 -0
  44. package/graph-provider/fuli_graph/project_agent_executor_models.py +35 -7
  45. package/graph-provider/fuli_graph/project_agent_memory_models.py +79 -0
  46. package/graph-provider/fuli_graph/project_agent_memory_routes.py +46 -0
  47. package/graph-provider/fuli_graph/project_agent_models.py +9 -0
  48. package/graph-provider/fuli_graph/project_agent_recruitment_values.py +48 -0
  49. package/graph-provider/fuli_graph/project_agent_routes.py +50 -1
  50. package/graph-provider/fuli_graph/project_agent_task_models.py +220 -29
  51. package/graph-provider/fuli_graph/runtime.py +37 -1
  52. package/graph-provider/fuli_graph/store.py +6 -0
  53. package/graph-provider/fuli_graph/store_project_agent_context.py +65 -0
  54. package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +122 -0
  55. package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +175 -11
  56. package/graph-provider/fuli_graph/store_project_agent_executor_routing.py +33 -10
  57. package/graph-provider/fuli_graph/store_project_agent_executors.py +181 -14
  58. package/graph-provider/fuli_graph/store_project_agent_memory.py +174 -0
  59. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +423 -0
  60. package/graph-provider/fuli_graph/store_project_agent_task_persistence.py +152 -0
  61. package/graph-provider/fuli_graph/store_project_agent_task_reads.py +187 -21
  62. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +756 -164
  63. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +396 -7
  64. package/graph-provider/fuli_graph/store_project_agent_tasks.py +265 -465
  65. package/graph-provider/fuli_graph/store_project_agents.py +214 -46
  66. package/graph-provider/fuli_graph/store_task_contexts.py +217 -0
  67. package/graph-provider/fuli_graph/store_transactions.py +40 -0
  68. package/graph-provider/fuli_graph/task_context_models.py +50 -0
  69. package/graph-provider/fuli_graph/task_context_routes.py +31 -0
  70. package/graph-provider/fuli_graph/workflow_observation_models.py +1 -1
  71. package/graph-provider/pyproject.toml +1 -1
  72. package/npm-shrinkwrap.json +8 -8
  73. package/package.json +5 -2
  74. package/skills/capturing-session-knowledge/SKILL.md +18 -6
  75. package/src/adaptive-runtime/executor-pool.js +46 -5
  76. package/src/adaptive-runtime/graph-services.js +10 -3
  77. package/src/adaptive-runtime/lease-client.js +66 -14
  78. package/src/adaptive-runtime/runtime-broker.js +24 -9
  79. package/src/agent-tools/employee-definitions.js +43 -0
  80. package/src/agent-tools/graph-definitions.js +48 -6
  81. package/src/agent-tools/graph-handlers.js +15 -0
  82. package/src/agent-tools/project-agent-definitions.js +35 -4
  83. package/src/agent-tools/project-agent-memory-definitions.js +36 -0
  84. package/src/agent-tools.js +6 -2
  85. package/src/agents/codex/lifecycle-hook.js +107 -0
  86. package/src/agents/codex/lifecycle-hooks.js +231 -0
  87. package/src/agents/cursor/lifecycle-hook.js +133 -0
  88. package/src/agents/cursor/lifecycle-hooks.js +50 -0
  89. package/src/agents/lifecycle-message.js +19 -0
  90. package/src/app/agent-request-context.js +23 -0
  91. package/src/cli/command-registry.js +2 -0
  92. package/src/cli/employee-command.js +17 -0
  93. package/src/cli/remote-mcp-command.js +282 -0
  94. package/src/cli/setup-command.js +5 -0
  95. package/src/cli.js +10 -0
  96. package/src/employees/catalog/jefa.json +21 -0
  97. package/src/employees/install-package.js +77 -0
  98. package/src/employees/management-policy.js +88 -0
  99. package/src/employees/manifest.js +53 -0
  100. package/src/employees/package-registry.js +107 -0
  101. package/src/employees/project-selection.js +50 -0
  102. package/src/employees/recruitment.js +199 -0
  103. package/src/employees/service.js +83 -0
  104. package/src/employees/task-entry.js +39 -0
  105. package/src/graphiti/agent-knowledge-workflows.js +135 -25
  106. package/src/graphiti/federated-application.js +78 -35
  107. package/src/graphiti/project-agent-control-plane.js +32 -3
  108. package/src/graphiti/project-agent-mapping.js +175 -4
  109. package/src/graphiti/project-agent-memory.js +115 -0
  110. package/src/graphiti/project-agent-task-entry.js +126 -0
  111. package/src/graphiti/project-agent-workflows.js +49 -14
  112. package/src/graphiti/provider-client.js +78 -4
  113. package/src/http/api-router.js +25 -5
  114. package/src/http/employee-api-router.js +64 -0
  115. package/src/http/error-mapping.js +4 -0
  116. package/src/http/graph-api-router.js +14 -0
  117. package/src/http/request-policy.js +6 -3
  118. package/src/http/static-handler.js +4 -3
  119. package/src/mcp/create-mcp-server.js +98 -37
  120. package/src/mcp/direct-call.js +38 -0
  121. package/src/mcp/instructions.js +8 -7
  122. package/src/mcp/provider-task-context-registry.js +74 -0
  123. package/src/mcp/remote-http-server.js +761 -0
  124. package/src/mcp/session-id.js +29 -3
  125. package/src/mcp/task-context-registry.js +88 -29
  126. package/src/mcp/tool-annotations.js +12 -2
  127. package/src/mcp/tool-result.js +8 -3
  128. package/src/mcp-server.js +20 -8
  129. package/src/native-runtime/runtime.js +58 -8
  130. package/src/server.js +76 -28
  131. package/src/setup/agent-installation-status.js +38 -6
  132. package/src/setup/agents.js +20 -8
  133. package/src/setup/claude-code-config.js +55 -13
  134. package/src/setup/codex-bootstrap.js +11 -10
  135. package/src/setup/codex-config.js +74 -7
  136. package/src/setup/config-backup.js +18 -2
  137. package/src/setup/cursor-config.js +22 -5
  138. package/src/setup/setup.js +30 -22
  139. package/src/storage/json-file.js +11 -3
  140. package/dist/web/assets/KnowledgeEditDialog-DSe3Gwep.js +0 -1
  141. package/dist/web/assets/KnowledgePage-vYYXipOi.js +0 -1
  142. package/dist/web/assets/PersonalProjectsPage-B7AlLEwf.js +0 -1
  143. package/dist/web/assets/ProjectAgentsPage-CgftpIPB.css +0 -1
  144. package/dist/web/assets/ProjectAgentsPage-DTHcL_ox.js +0 -11
  145. package/dist/web/assets/SearchableSelect-Dz13ClQb.js +0 -1
  146. package/dist/web/assets/conversation-launcher-settings-DTYxiLJt.js +0 -1
  147. package/dist/web/assets/index-BnH5mcZx.css +0 -1
  148. package/dist/web/assets/index-C67Q0JY2.js +0 -2
@@ -134,13 +134,19 @@ class StoreProjectAgents:
134
134
  if request.profile.agent_type == 'temporary'
135
135
  else 'reviewed_agent'
136
136
  )
137
- await self.runtime.driver.execute_query(
137
+ records, _, _ = await self.runtime.driver.execute_query(
138
138
  '''
139
139
  MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
140
140
  MERGE (agent:FuliProjectAgent {id: $id})
141
141
  ON CREATE SET agent.agent_id = $agent_id,
142
142
  agent.created_at = $updated_at,
143
143
  agent.system_managed = false
144
+ // Acquire the node write lock before reading lifecycle status so a
145
+ // concurrent archive cannot be lost to this compatibility upsert.
146
+ SET agent._profile_upsert_lock = true
147
+ REMOVE agent._profile_upsert_lock
148
+ WITH space, agent
149
+ WHERE agent.status IS NULL OR agent.status <> 'archived'
144
150
  SET agent.profile_json = $profile_json,
145
151
  agent.name = $name,
146
152
  agent.occupation_emoji = $occupation_emoji,
@@ -175,20 +181,37 @@ class StoreProjectAgents:
175
181
  recruitment_id=recruitment_id,
176
182
  updated_at=updated_at,
177
183
  )
184
+ if not records:
185
+ raise HTTPException(
186
+ status_code=409,
187
+ detail=(
188
+ 'archived Project Agents require a dedicated restore operation'
189
+ ),
190
+ )
178
191
  if request.personal_project_id:
179
- await self._upsert_legacy_assignment(request, updated_at)
192
+ if not await self._upsert_legacy_assignment(request, updated_at):
193
+ raise HTTPException(
194
+ status_code=409,
195
+ detail=(
196
+ 'archived Project Agents require a dedicated '
197
+ 'restore operation'
198
+ ),
199
+ )
180
200
  return await self.get_project_agent(
181
201
  actor,
182
202
  request.personal_space_id,
183
203
  request.personal_project_id,
184
204
  request.agent_id,
205
+ include_inactive_project_assignment=bool(
206
+ request.personal_project_id
207
+ ),
185
208
  )
186
209
 
187
210
  async def _upsert_legacy_assignment(
188
211
  self,
189
212
  request: ProjectAgentUpsert,
190
213
  updated_at,
191
- ) -> None:
214
+ ) -> bool:
192
215
  assignment_id = stable_uuid(
193
216
  self.settings.provider_id,
194
217
  request.personal_space_id,
@@ -197,7 +220,7 @@ class StoreProjectAgents:
197
220
  request.personal_project_id,
198
221
  request.agent_id,
199
222
  )
200
- await self.runtime.driver.execute_query(
223
+ records, _, _ = await self.runtime.driver.execute_query(
201
224
  '''
202
225
  MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
203
226
  [:CONTAINS_PROJECT]->
@@ -205,20 +228,62 @@ class StoreProjectAgents:
205
228
  MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
206
229
  [:HAS_PROJECT_AGENT_IDENTITY]->
207
230
  (agent:FuliProjectAgent {agent_id: $agent_id})
208
- MERGE (assignment:FuliProjectAgentAssignment {id: $assignment_id})
209
- ON CREATE SET assignment.assignment_id = $assignment_id,
210
- assignment.assigned_at = $updated_at,
211
- assignment.revision = 0,
212
- assignment.reason = 'legacy project Agent profile'
213
- SET assignment.responsibility = $responsibility,
214
- assignment.work_kinds = $work_kinds,
215
- assignment.capabilities = $capabilities,
216
- assignment.model_strategy_json = $model_strategy_json,
217
- assignment.executor_policy_json = $executor_policy_json,
218
- assignment.status = 'active',
219
- assignment.updated_at = $updated_at
220
- MERGE (project)-[:HAS_PROJECT_AGENT_ASSIGNMENT]->(assignment)
221
- MERGE (assignment)-[:ASSIGNED_AGENT]->(agent)
231
+ SET agent._task_lifecycle_lock = true
232
+ REMOVE agent._task_lifecycle_lock
233
+ WITH project, agent
234
+ WHERE agent.status <> 'archived'
235
+ OPTIONAL MATCH (project)-[:HAS_PROJECT_AGENT_ASSIGNMENT]->
236
+ (active_assignment:FuliProjectAgentAssignment)-
237
+ [:ASSIGNED_AGENT]->(agent)
238
+ WHERE active_assignment.status = 'active'
239
+ OPTIONAL MATCH (project)-[:HAS_PROJECT_AGENT_ASSIGNMENT]->
240
+ (handed_off_assignment:FuliProjectAgentAssignment)-
241
+ [:ASSIGNED_AGENT]->(agent)
242
+ WHERE handed_off_assignment.status = 'ended'
243
+ AND handed_off_assignment.replaced_by_assignment_id IS NOT NULL
244
+ WITH project, agent,
245
+ count(DISTINCT active_assignment) AS active_assignment_count,
246
+ count(DISTINCT CASE
247
+ WHEN active_assignment.id = $assignment_id
248
+ THEN active_assignment
249
+ END) AS legacy_assignment_count,
250
+ count(DISTINCT handed_off_assignment)
251
+ AS handed_off_assignment_count
252
+ // An audited handoff is terminal for the backwards-compatible
253
+ // upsert path. Reassignment must use the explicit assignment API.
254
+ // An already-active legacy assignment may still refresh its
255
+ // profile-backed fields even when another historical role moved.
256
+ FOREACH (_ IN CASE
257
+ WHEN legacy_assignment_count > 0 OR (
258
+ active_assignment_count = 0
259
+ AND handed_off_assignment_count = 0
260
+ )
261
+ THEN [1] ELSE [] END |
262
+ MERGE (assignment:FuliProjectAgentAssignment {id: $assignment_id})
263
+ ON CREATE SET assignment.assignment_id = $assignment_id,
264
+ assignment.assigned_at = $updated_at,
265
+ assignment.revision = 0,
266
+ assignment.reason = 'legacy project Agent profile'
267
+ SET assignment.revision = CASE
268
+ WHEN assignment.status IS NOT NULL
269
+ AND assignment.status <> 'active'
270
+ THEN coalesce(assignment.revision, 0) + 1
271
+ ELSE coalesce(assignment.revision, 0)
272
+ END,
273
+ assignment.ended_at = NULL,
274
+ assignment.end_reason = NULL,
275
+ assignment.replaced_by_assignment_id = NULL
276
+ SET assignment.responsibility = $responsibility,
277
+ assignment.work_kinds = $work_kinds,
278
+ assignment.capabilities = $capabilities,
279
+ assignment.model_strategy_json = $model_strategy_json,
280
+ assignment.executor_policy_json = $executor_policy_json,
281
+ assignment.status = 'active',
282
+ assignment.updated_at = $updated_at
283
+ MERGE (project)-[:HAS_PROJECT_AGENT_ASSIGNMENT]->(assignment)
284
+ MERGE (assignment)-[:ASSIGNED_AGENT]->(agent)
285
+ )
286
+ RETURN agent.agent_id AS agent_id
222
287
  ''',
223
288
  personal_space_id=request.personal_space_id,
224
289
  personal_project_id=request.personal_project_id,
@@ -236,6 +301,7 @@ class StoreProjectAgents:
236
301
  ),
237
302
  updated_at=updated_at,
238
303
  )
304
+ return bool(records)
239
305
 
240
306
  async def list_project_agents(
241
307
  self,
@@ -273,6 +339,8 @@ class StoreProjectAgents:
273
339
  personal_space_id: str,
274
340
  personal_project_id: str | None,
275
341
  agent_id: str,
342
+ *,
343
+ include_inactive_project_assignment: bool = False,
276
344
  ) -> ProjectAgentRecord:
277
345
  self._require_personal()
278
346
  space = await self.authorize(actor, personal_space_id, 'reader')
@@ -285,13 +353,22 @@ class StoreProjectAgents:
285
353
  )
286
354
  records = await self._project_agent_rows(
287
355
  personal_space_id,
288
- personal_project_id=None,
356
+ personal_project_id=(
357
+ None
358
+ if include_inactive_project_assignment
359
+ else personal_project_id
360
+ ),
289
361
  agent_id=agent_id,
290
362
  status=None,
291
363
  capability=None,
292
364
  )
293
365
  if not records:
294
366
  raise HTTPException(status_code=404, detail='project Agent not found')
367
+ if personal_project_id and not any(
368
+ dict(item).get('personal_project_id') == personal_project_id
369
+ for item in records[0].get('assignment_rows') or []
370
+ ):
371
+ raise HTTPException(status_code=404, detail='project Agent not found')
295
372
  return self._project_agent_from_row(
296
373
  records[0],
297
374
  personal_space_id,
@@ -322,6 +399,11 @@ class StoreProjectAgents:
322
399
  MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
323
400
  [:HAS_PROJECT_AGENT_IDENTITY]->
324
401
  (agent:FuliProjectAgent {agent_id: $agent_id})
402
+ // Serialize task-participant creation with lifecycle termination.
403
+ // The lock must be taken before the open-work snapshot is read.
404
+ SET agent._task_lifecycle_lock = true
405
+ REMOVE agent._task_lifecycle_lock
406
+ WITH space, agent
325
407
  OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
326
408
  (task:FuliProjectAgentTask)-
327
409
  [participant:HAS_PARTICIPANT]->(agent)
@@ -337,7 +419,7 @@ class StoreProjectAgents:
337
419
  ]] AS open_tasks
338
420
  WHERE agent.status = 'archived' OR size(open_tasks) = 0
339
421
  SET agent.status = 'archived',
340
- agent.archive_reason = $reason,
422
+ agent.archive_reason = coalesce(agent.archive_reason, $reason),
341
423
  agent.archived_at = coalesce(agent.archived_at, $updated_at),
342
424
  agent.updated_at = $updated_at
343
425
  WITH space, agent
@@ -355,7 +437,7 @@ class StoreProjectAgents:
355
437
  ''',
356
438
  personal_space_id=personal_space_id,
357
439
  agent_id=agent_id,
358
- reason=reason,
440
+ reason=reason.strip(),
359
441
  updated_at=now_utc(),
360
442
  )
361
443
  if not records:
@@ -420,30 +502,64 @@ class StoreProjectAgents:
420
502
  WITH space, agent, collect(DISTINCT {
421
503
  assignment: assignment,
422
504
  personal_project_id: project.project_id
423
- }) AS assignment_rows
505
+ }) AS all_assignment_rows
506
+ WITH space, agent,
507
+ [item IN all_assignment_rows
508
+ WHERE $personal_project_id IS NULL
509
+ OR item.personal_project_id = $personal_project_id]
510
+ AS assignment_rows
424
511
  OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
425
512
  (task:FuliProjectAgentTask)-
426
513
  [participant:HAS_PARTICIPANT]->(agent)
427
514
  WITH space, agent, assignment_rows,
428
515
  collect(DISTINCT {
429
516
  task_id: task.task_id,
517
+ personal_project_id: task.personal_project_id,
430
518
  status: participant.status,
431
519
  updated_at: participant.updated_at,
432
520
  source_application: task.source_application
433
- }) AS task_rows,
434
- collect(DISTINCT task.source_application) AS task_clients
521
+ }) AS all_task_rows,
522
+ collect(DISTINCT {
523
+ personal_project_id: task.personal_project_id,
524
+ source_application: task.source_application
525
+ }) AS all_task_client_rows
526
+ WITH space, agent, assignment_rows,
527
+ [item IN all_task_rows
528
+ WHERE $personal_project_id IS NULL
529
+ OR item.personal_project_id = $personal_project_id]
530
+ AS task_rows,
531
+ [item IN all_task_client_rows
532
+ WHERE ($personal_project_id IS NULL
533
+ OR item.personal_project_id = $personal_project_id)
534
+ AND item.source_application IS NOT NULL]
535
+ AS task_client_rows
435
536
  OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
436
- (:FuliProjectAgentTask)-[:HAS_TASK_EVENT]->
537
+ (event_task:FuliProjectAgentTask)-[:HAS_TASK_EVENT]->
437
538
  (event:FuliProjectAgentTaskEvent)-[:EVENT_AGENT]->(agent)
438
539
  WITH agent,
439
540
  assignment_rows,
440
541
  task_rows,
441
- task_clients,
442
- collect(DISTINCT event.source_application) AS event_clients
542
+ task_client_rows,
543
+ collect(DISTINCT {
544
+ personal_project_id: event_task.personal_project_id,
545
+ source_application: event.source_application
546
+ }) AS all_event_client_rows
547
+ WITH agent,
548
+ assignment_rows,
549
+ task_rows,
550
+ task_client_rows,
551
+ [item IN all_event_client_rows
552
+ WHERE ($personal_project_id IS NULL
553
+ OR item.personal_project_id = $personal_project_id)
554
+ AND item.source_application IS NOT NULL]
555
+ AS event_client_rows
443
556
  RETURN agent,
444
557
  assignment_rows,
445
558
  task_rows,
446
- task_clients + event_clients AS observed_clients
559
+ [item IN task_client_rows | item.source_application]
560
+ + [item IN event_client_rows | item.source_application]
561
+ AS observed_clients,
562
+ task_client_rows + event_client_rows AS observed_client_rows
447
563
  ORDER BY CASE agent.agent_type
448
564
  WHEN 'coordinator' THEN 0
449
565
  WHEN 'hr' THEN 1
@@ -500,6 +616,10 @@ class StoreProjectAgents:
500
616
  MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
501
617
  [:HAS_PROJECT_AGENT_IDENTITY]->
502
618
  (agent:FuliProjectAgent {agent_id: $agent_id})
619
+ SET agent._task_lifecycle_lock = true
620
+ REMOVE agent._task_lifecycle_lock
621
+ WITH project, agent
622
+ WHERE agent.status = 'active'
503
623
  MERGE (assignment:FuliProjectAgentAssignment {id: $assignment_id})
504
624
  ON CREATE SET assignment.assignment_id = $assignment_id,
505
625
  assignment.payload_hash = $payload_hash,
@@ -548,7 +668,10 @@ class StoreProjectAgents:
548
668
  updated_at=updated_at,
549
669
  )
550
670
  if not records:
551
- raise HTTPException(status_code=404, detail='project or Agent not found')
671
+ raise HTTPException(
672
+ status_code=409,
673
+ detail='project Agent is not active or is unavailable',
674
+ )
552
675
  assignment = dict(records[0]['assignment'])
553
676
  if assignment.get('payload_hash') != payload_hash:
554
677
  raise HTTPException(
@@ -634,13 +757,16 @@ class StoreProjectAgents:
634
757
  (assignment:FuliProjectAgentAssignment {
635
758
  assignment_id: $assignment_id
636
759
  })-[:ASSIGNED_AGENT]->(agent:FuliProjectAgent)
760
+ SET agent._task_lifecycle_lock = true
761
+ REMOVE agent._task_lifecycle_lock
762
+ WITH project, assignment, agent
637
763
  WHERE assignment.status = 'active'
638
- AND assignment.revision = $expected_revision
764
+ AND coalesce(assignment.revision, 0) = $expected_revision
639
765
  SET assignment.status = 'ended',
640
766
  assignment.end_reason = $reason,
641
767
  assignment.ended_at = $updated_at,
642
768
  assignment.updated_at = $updated_at,
643
- assignment.revision = assignment.revision + 1
769
+ assignment.revision = coalesce(assignment.revision, 0) + 1
644
770
  RETURN assignment, project.project_id AS personal_project_id,
645
771
  agent.agent_id AS agent_id
646
772
  ''',
@@ -683,7 +809,7 @@ class StoreProjectAgents:
683
809
  space,
684
810
  request.personal_project_id,
685
811
  request.replacement_agent_id,
686
- require_active=True,
812
+ require_active=False,
687
813
  allow_unassigned=True,
688
814
  )
689
815
  replacement_id = stable_uuid(
@@ -708,16 +834,29 @@ class StoreProjectAgents:
708
834
  (replacement_agent:FuliProjectAgent {
709
835
  agent_id: $replacement_agent_id
710
836
  })
837
+ WITH project, ended, ended_agent, replacement_agent,
838
+ CASE
839
+ WHEN ended_agent.agent_id <= replacement_agent.agent_id
840
+ THEN [ended_agent, replacement_agent]
841
+ ELSE [replacement_agent, ended_agent]
842
+ END AS lifecycle_agents
843
+ UNWIND lifecycle_agents AS lifecycle_agent
844
+ SET lifecycle_agent._task_lifecycle_lock = true
845
+ REMOVE lifecycle_agent._task_lifecycle_lock
846
+ WITH project, ended, ended_agent, replacement_agent,
847
+ collect(lifecycle_agent) AS locked_agents
711
848
  OPTIONAL MATCH (existing_replacement:FuliProjectAgentAssignment {
712
849
  id: $replacement_id
713
850
  })
714
851
  WITH project, ended, ended_agent, replacement_agent,
715
852
  existing_replacement
716
853
  WHERE existing_replacement IS NOT NULL
717
- OR (ended.status = 'active'
718
- AND ended.revision = $expected_revision)
854
+ OR (replacement_agent.status = 'active'
855
+ AND ended_agent.status = 'active'
856
+ AND ended.status = 'active'
857
+ AND coalesce(ended.revision, 0) = $expected_revision)
719
858
  OR (ended.status = 'ended'
720
- AND ended.revision = $expected_revision + 1
859
+ AND coalesce(ended.revision, 0) = $expected_revision + 1
721
860
  AND ended.replaced_by_assignment_id = $replacement_id)
722
861
  MERGE (replacement:FuliProjectAgentAssignment {id: $replacement_id})
723
862
  ON CREATE SET replacement.assignment_id = $replacement_id,
@@ -737,10 +876,13 @@ class StoreProjectAgents:
737
876
  replacement.updated_at = $updated_at
738
877
  WITH project, ended, ended_agent, replacement_agent, replacement,
739
878
  replacement.payload_hash = $payload_hash AS payload_matches,
740
- (ended.status = 'active'
741
- AND ended.revision = $expected_revision) AS can_apply,
879
+ (replacement_agent.status = 'active'
880
+ AND ended_agent.status = 'active'
881
+ AND ended.status = 'active'
882
+ AND coalesce(ended.revision, 0) = $expected_revision)
883
+ AS can_apply,
742
884
  (ended.status = 'ended'
743
- AND ended.revision = $expected_revision + 1
885
+ AND coalesce(ended.revision, 0) = $expected_revision + 1
744
886
  AND ended.replaced_by_assignment_id = $replacement_id)
745
887
  AS exact_replay
746
888
  FOREACH (_ IN CASE
@@ -752,7 +894,7 @@ class StoreProjectAgents:
752
894
  ended.ended_at = $updated_at,
753
895
  ended.updated_at = $updated_at,
754
896
  ended.replaced_by_assignment_id = $replacement_id,
755
- ended.revision = ended.revision + 1
897
+ ended.revision = coalesce(ended.revision, 0) + 1
756
898
  )
757
899
  RETURN ended, replacement,
758
900
  ended_agent.agent_id AS ended_agent_id,
@@ -849,7 +991,14 @@ class StoreProjectAgents:
849
991
  value = dict(item)
850
992
  assignment_raw = value.get('assignment')
851
993
  project_id = value.get('personal_project_id')
852
- if assignment_raw is None or not project_id:
994
+ if (
995
+ assignment_raw is None
996
+ or not project_id
997
+ or (
998
+ projection_project_id is not None
999
+ and project_id != projection_project_id
1000
+ )
1001
+ ):
853
1002
  continue
854
1003
  assignments.append(
855
1004
  self._assignment(
@@ -871,7 +1020,13 @@ class StoreProjectAgents:
871
1020
  tasks = [
872
1021
  dict(value)
873
1022
  for value in (row.get('task_rows') or [])
874
- if value and value.get('task_id') and value.get('status') in nonterminal
1023
+ if value
1024
+ and value.get('task_id')
1025
+ and value.get('status') in nonterminal
1026
+ and (
1027
+ projection_project_id is None
1028
+ or value.get('personal_project_id') == projection_project_id
1029
+ )
875
1030
  ]
876
1031
  priority = {
877
1032
  'running': 0,
@@ -888,13 +1043,26 @@ class StoreProjectAgents:
888
1043
  if item.get('updated_at') else 0),
889
1044
  )
890
1045
  )
891
- work_status = 'ended' if (
892
- profile.agent_type == 'temporary' and profile.status == 'archived'
893
- ) else (tasks[0]['status'] if tasks else 'idle')
1046
+ work_status = (
1047
+ 'ended'
1048
+ if profile.status == 'archived'
1049
+ else (tasks[0]['status'] if tasks else 'idle')
1050
+ )
1051
+ observed_client_rows = row.get('observed_client_rows')
1052
+ if projection_project_id is not None:
1053
+ observed_values = [
1054
+ dict(value).get('source_application')
1055
+ for value in observed_client_rows or []
1056
+ if dict(value).get('personal_project_id') == projection_project_id
1057
+ ]
1058
+ else:
1059
+ observed_values = row.get('observed_clients') or []
894
1060
  observed_clients = sorted({
895
1061
  value
896
- for value in (row.get('observed_clients') or [])
897
- if value in {'codex', 'claude_code', 'cursor', 'kiro', 'other'}
1062
+ for value in observed_values
1063
+ if value in {
1064
+ 'codex', 'claude', 'claude_code', 'cursor', 'kiro', 'other'
1065
+ }
898
1066
  })
899
1067
  return ProjectAgentRecord(
900
1068
  agent_id=raw['agent_id'],