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
@@ -32,10 +32,13 @@ from .project_agent_executor_models import (
32
32
  ProjectAgentExecutorScope,
33
33
  project_agent_model_strategy_key,
34
34
  )
35
- from .project_agent_models import ProjectAgentExecutorPolicy, ProjectAgentModelStrategy
35
+ from .project_agent_models import (
36
+ ProjectAgentExecutorPolicy, ProjectAgentModelStrategy, ProjectAgentProfile,
37
+ )
36
38
  from .provider_values import native_datetime, now_utc, stable_uuid
37
39
  from .store_project_agent_executor_learning import StoreProjectAgentExecutorLearning
38
40
  from .store_project_agent_executor_routing import StoreProjectAgentExecutorRouting
41
+ from .store_transactions import query_store_transaction
39
42
 
40
43
 
41
44
  class StoreProjectAgentExecutors(
@@ -131,6 +134,8 @@ class StoreProjectAgentExecutors(
131
134
  (executor:FuliProjectAgentExecutor {
132
135
  executor_id: $executor_id
133
136
  })
137
+ SET executor._authorization_write_lock = true
138
+ REMOVE executor._authorization_write_lock
134
139
  WITH executor, permission,
135
140
  coalesce(
136
141
  permission.authorization_idempotency_key = $idempotency_key,
@@ -251,14 +256,27 @@ class StoreProjectAgentExecutors(
251
256
  (executor:FuliProjectAgentExecutor {
252
257
  executor_id: $executor_id
253
258
  })
259
+ SET executor._preflight_write_lock = true
260
+ REMOVE executor._preflight_write_lock
254
261
  WITH executor, permission,
255
262
  coalesce(
256
263
  executor.preflight_idempotency_key = $idempotency_key,
257
264
  false
258
265
  ) AS same_key
259
- WHERE (same_key AND executor.preflight_payload_hash = $payload_hash)
260
- OR NOT same_key
261
- FOREACH (_ IN CASE WHEN same_key THEN [] ELSE [1] END |
266
+ WITH executor, permission, same_key,
267
+ (
268
+ (same_key
269
+ AND executor.preflight_payload_hash = $payload_hash)
270
+ OR (
271
+ NOT same_key
272
+ AND (
273
+ executor.preflight_at IS NULL
274
+ OR executor.preflight_at < $checked_at
275
+ )
276
+ )
277
+ ) AS accepted
278
+ FOREACH (_ IN CASE
279
+ WHEN accepted AND NOT same_key THEN [1] ELSE [] END |
262
280
  SET executor.preflight_status = $preflight_status,
263
281
  executor.preflight_passed = $preflight_passed,
264
282
  executor.preflight_reason = $reason,
@@ -271,7 +289,7 @@ class StoreProjectAgentExecutors(
271
289
  executor.updated_at = $timestamp,
272
290
  executor.revision = coalesce(executor.revision, 0) + 1
273
291
  )
274
- RETURN executor, permission
292
+ RETURN executor, permission, accepted
275
293
  ''',
276
294
  personal_space_id=request.personal_space_id,
277
295
  executor_id=request.executor_id,
@@ -291,6 +309,11 @@ class StoreProjectAgentExecutors(
291
309
  )
292
310
  if not records:
293
311
  raise HTTPException(status_code=404, detail='registered executor not found')
312
+ if not records[0].get('accepted'):
313
+ raise HTTPException(
314
+ status_code=409,
315
+ detail='executor preflight report is stale or its idempotency key was reused',
316
+ )
294
317
  return self._executor_from_row(records[0], request.personal_space_id)
295
318
 
296
319
  async def record_project_agent_executor_health(
@@ -309,14 +332,26 @@ class StoreProjectAgentExecutors(
309
332
  (executor:FuliProjectAgentExecutor {
310
333
  executor_id: $executor_id
311
334
  })
335
+ SET executor._health_write_lock = true
336
+ REMOVE executor._health_write_lock
312
337
  WITH executor, permission,
313
338
  coalesce(
314
339
  executor.health_idempotency_key = $idempotency_key,
315
340
  false
316
341
  ) AS same_key
317
- WHERE (same_key AND executor.health_payload_hash = $payload_hash)
318
- OR NOT same_key
319
- FOREACH (_ IN CASE WHEN same_key THEN [] ELSE [1] END |
342
+ WITH executor, permission, same_key,
343
+ (
344
+ (same_key AND executor.health_payload_hash = $payload_hash)
345
+ OR (
346
+ NOT same_key
347
+ AND (
348
+ executor.health_checked_at IS NULL
349
+ OR executor.health_checked_at < $checked_at
350
+ )
351
+ )
352
+ ) AS accepted
353
+ FOREACH (_ IN CASE
354
+ WHEN accepted AND NOT same_key THEN [1] ELSE [] END |
320
355
  SET executor.health_status = $health_status,
321
356
  executor.health_reason = $reason,
322
357
  executor.health_checked_at = $checked_at,
@@ -325,7 +360,7 @@ class StoreProjectAgentExecutors(
325
360
  executor.updated_at = $timestamp,
326
361
  executor.revision = coalesce(executor.revision, 0) + 1
327
362
  )
328
- RETURN executor, permission
363
+ RETURN executor, permission, accepted
329
364
  ''',
330
365
  personal_space_id=request.personal_space_id,
331
366
  executor_id=request.executor_id,
@@ -338,6 +373,11 @@ class StoreProjectAgentExecutors(
338
373
  )
339
374
  if not records:
340
375
  raise HTTPException(status_code=404, detail='registered executor not found')
376
+ if not records[0].get('accepted'):
377
+ raise HTTPException(
378
+ status_code=409,
379
+ detail='executor health report is stale or its idempotency key was reused',
380
+ )
341
381
  return self._executor_from_row(records[0], request.personal_space_id)
342
382
 
343
383
  async def list_project_agent_executors(
@@ -363,6 +403,10 @@ class StoreProjectAgentExecutors(
363
403
  AND executor.preflight_status = 'passed'
364
404
  AND coalesce(executor.workspace_permission, false) = true
365
405
  AND executor.health_status <> 'unhealthy'
406
+ AND (
407
+ coalesce(executor.health_required, false) = false
408
+ OR executor.health_status = 'healthy'
409
+ )
366
410
  ))
367
411
  RETURN executor, permission
368
412
  ORDER BY coalesce(executor.global_priority, 100), executor.executor_id
@@ -676,6 +720,10 @@ class StoreProjectAgentExecutors(
676
720
  ) -> ProjectAgentExecutorActualReport:
677
721
  """Persist the executor/model actually observed by a Task Run."""
678
722
 
723
+ async with query_store_transaction(self) as scoped:
724
+ return await scoped._record_project_agent_executor_actual(actor, request)
725
+
726
+ async def _record_project_agent_executor_actual(self, actor, request):
679
727
  self._require_personal()
680
728
  space = await self.authorize(actor, request.personal_space_id, 'maintainer')
681
729
  await authorize_personal_project(
@@ -684,6 +732,33 @@ class StoreProjectAgentExecutors(
684
732
  space,
685
733
  request.personal_project_id,
686
734
  )
735
+ # Explicit ordering also applies when activity already owns the task lock.
736
+ # Profile/preflight/revocation writers cannot change validated state until
737
+ # this observation and its projection commit together.
738
+ for target, match in (
739
+ ('task', '''MATCH (task:FuliProjectAgentTask {
740
+ personal_space_id: $personal_space_id,
741
+ personal_project_id: $personal_project_id, task_id: $task_id
742
+ })'''),
743
+ ('agent', '''MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})
744
+ -[:HAS_PROJECT_AGENT_IDENTITY]->
745
+ (agent:FuliProjectAgent {agent_id: $agent_id})'''),
746
+ ('executor', '''MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})
747
+ -[:HAS_EXECUTOR_PERMISSION]->
748
+ (executor:FuliProjectAgentExecutor {executor_id: $executor_id})'''),
749
+ ('permission', '''MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})
750
+ -[permission:HAS_EXECUTOR_PERMISSION]->
751
+ (:FuliProjectAgentExecutor {executor_id: $executor_id})'''),
752
+ ):
753
+ await self.runtime.driver.execute_query(
754
+ f'{match} SET {target}._actual_write_lock = true '
755
+ f'REMOVE {target}._actual_write_lock RETURN count(*) AS locked',
756
+ personal_space_id=request.personal_space_id,
757
+ personal_project_id=request.personal_project_id,
758
+ task_id=request.task_id,
759
+ agent_id=request.agent_id,
760
+ executor_id=request.executor_id,
761
+ )
687
762
  validation_records, _, _ = await self.runtime.driver.execute_query(
688
763
  '''
689
764
  MATCH (task:FuliProjectAgentTask {
@@ -718,7 +793,58 @@ class StoreProjectAgentExecutors(
718
793
  detail='actual executor is not registered, authorized, and preflighted',
719
794
  )
720
795
  validation_row = validation_records[0]
796
+ profile_json = dict(validation_row.get('agent') or {}).get('profile_json')
797
+ if profile_json:
798
+ profile = ProjectAgentProfile.model_validate_json(profile_json)
799
+ if (request.source_application or 'other') not in profile.allowed_clients:
800
+ raise HTTPException(
801
+ status_code=403,
802
+ detail='project Agent is not available to this source client',
803
+ )
721
804
  task_raw = dict(validation_row.get('task') or {})
805
+ current_projection_exists = any(task_raw.get(key) for key in (
806
+ 'actual_executor_id', 'actual_model_provider', 'actual_model'))
807
+ current_actual_at = native_datetime(task_raw.get('actual_occurred_at'))
808
+ if current_projection_exists and current_actual_at is None and task_raw.get('actual_run_id'):
809
+ previous, _, _ = await self.runtime.driver.execute_query('''
810
+ MATCH (task:FuliProjectAgentTask {
811
+ personal_space_id: $personal_space_id,
812
+ personal_project_id: $personal_project_id, task_id: $task_id
813
+ })-[:HAS_EXECUTOR_OBSERVATION]->(observation {
814
+ run_id: $actual_run_id
815
+ })
816
+ WHERE observation.executor_id = task.actual_executor_id
817
+ AND observation.provider = task.actual_model_provider
818
+ AND observation.model = task.actual_model
819
+ AND observation.agent_id = $agent_id
820
+ AND (
821
+ observation.projection_applied = true
822
+ OR (
823
+ observation.projection_applied IS NULL
824
+ AND observation.projection_considered IS NULL
825
+ )
826
+ )
827
+ RETURN observation.occurred_at AS occurred_at
828
+ ''', personal_space_id=request.personal_space_id,
829
+ personal_project_id=request.personal_project_id,
830
+ task_id=request.task_id, actual_run_id=task_raw['actual_run_id'],
831
+ agent_id=request.agent_id, routing_='r')
832
+ previous_times = [
833
+ native_datetime(row.get('occurred_at')) for row in previous
834
+ ]
835
+ if previous_times and all(
836
+ value is not None
837
+ and callable(getattr(value, 'utcoffset', None))
838
+ and value.utcoffset() is not None
839
+ for value in previous_times
840
+ ):
841
+ current_actual_at = max(previous_times)
842
+ projection_allowed = not current_projection_exists
843
+ if current_projection_exists and current_actual_at is not None:
844
+ projection_allowed = (
845
+ current_actual_at.utcoffset() is not None
846
+ and request.occurred_at >= current_actual_at
847
+ )
722
848
  task_strategy = ProjectAgentModelStrategy()
723
849
  task_strategy_json = task_raw.get('effective_model_strategy_json')
724
850
  if task_strategy_json:
@@ -757,6 +883,11 @@ class StoreProjectAgentExecutors(
757
883
  status_code=409,
758
884
  detail='actual model was not reported by the latest passed preflight',
759
885
  )
886
+ if self._select_model([reported_model], task_strategy) is None:
887
+ raise HTTPException(
888
+ status_code=409,
889
+ detail='actual model does not satisfy the task model strategy',
890
+ )
760
891
  if (
761
892
  executor_record.health_status == 'unhealthy'
762
893
  or (
@@ -841,11 +972,18 @@ class StoreProjectAgentExecutors(
841
972
  observation.source_session_id = $source_session_id
842
973
  WITH task, observation,
843
974
  observation.payload_hash = $payload_hash AS payload_matches,
844
- coalesce(observation.projection_applied, false) = false
975
+ coalesce(observation.projection_considered,
976
+ observation.projection_applied, false) = false
845
977
  AS projection_pending
846
978
  FOREACH (_ IN CASE
847
979
  WHEN payload_matches AND projection_pending THEN [1] ELSE [] END |
848
980
  MERGE (task)-[:HAS_EXECUTOR_OBSERVATION]->(observation)
981
+ SET observation.projection_considered = true
982
+ )
983
+ FOREACH (_ IN CASE
984
+ WHEN payload_matches AND projection_pending
985
+ AND $projection_allowed
986
+ THEN [1] ELSE [] END |
849
987
  SET task.actual_executor_id = $executor_id,
850
988
  task.actual_run_id = $run_id,
851
989
  task.actual_model_provider = $provider,
@@ -854,7 +992,10 @@ class StoreProjectAgentExecutors(
854
992
  task.actual_model_strategy_source = $model_strategy_source,
855
993
  task.matched_executor_rule_id = $matched_rule_id,
856
994
  task.executor_fallback_reason = $fallback_reason,
857
- task.updated_at = $occurred_at,
995
+ task.actual_occurred_at = $occurred_at,
996
+ task.updated_at = CASE
997
+ WHEN task.updated_at IS NULL OR task.updated_at < $occurred_at
998
+ THEN $occurred_at ELSE task.updated_at END,
858
999
  observation.projection_applied = true
859
1000
  )
860
1001
  RETURN task, observation, payload_matches
@@ -876,6 +1017,7 @@ class StoreProjectAgentExecutors(
876
1017
  occurred_at=request.occurred_at,
877
1018
  source_application=request.source_application,
878
1019
  source_session_id=request.source_session_id,
1020
+ projection_allowed=projection_allowed,
879
1021
  )
880
1022
  if not records:
881
1023
  raise HTTPException(status_code=404, detail='project Agent task not found')
@@ -912,10 +1054,34 @@ class StoreProjectAgentExecutors(
912
1054
  (agent:FuliProjectAgent)
913
1055
  WHERE agent.test_source = $test_source
914
1056
  AND coalesce(agent.cleanup_eligible, false) = true
1057
+ WITH space, agent
1058
+ ORDER BY agent.agent_id
1059
+ SET agent._task_lifecycle_lock = true
1060
+ REMOVE agent._task_lifecycle_lock
1061
+ WITH space, agent
1062
+ OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_TASK]->
1063
+ (task:FuliProjectAgentTask)-
1064
+ [participant:HAS_PARTICIPANT]->(agent)
1065
+ WITH space, agent, collect(DISTINCT {
1066
+ task_id: task.task_id,
1067
+ status: participant.status
1068
+ }) AS tasks
1069
+ WITH space, agent, [item IN tasks
1070
+ WHERE item.task_id IS NOT NULL
1071
+ AND item.status IN [
1072
+ 'awaiting_recruitment', 'queued', 'running',
1073
+ 'paused', 'blocked', 'awaiting_review'
1074
+ ]] AS open_tasks
1075
+ WHERE agent.status = 'archived' OR size(open_tasks) = 0
1076
+ WITH space, agent,
1077
+ coalesce(agent.status, 'active') <> 'archived'
1078
+ AS newly_archived
915
1079
  SET agent.status = 'archived',
916
1080
  agent.updated_at = $updated_at,
917
- agent.archived_reason = 'test cleanup'
918
- WITH space, agent
1081
+ agent.archive_reason = coalesce(agent.archive_reason,
1082
+ 'test cleanup'),
1083
+ agent.archived_at = coalesce(agent.archived_at, $updated_at)
1084
+ WITH space, agent, newly_archived
919
1085
  OPTIONAL MATCH (space)-[:CONTAINS_PROJECT]->
920
1086
  (:FuliPersonalProject)-
921
1087
  [:HAS_PROJECT_AGENT_ASSIGNMENT]->
@@ -927,7 +1093,8 @@ class StoreProjectAgentExecutors(
927
1093
  assignment.ended_at = $updated_at,
928
1094
  assignment.updated_at = $updated_at,
929
1095
  assignment.revision = coalesce(assignment.revision, 0) + 1
930
- RETURN count(DISTINCT agent) AS archived_count
1096
+ RETURN count(DISTINCT CASE WHEN newly_archived THEN agent END)
1097
+ AS archived_count
931
1098
  ''',
932
1099
  personal_space_id=personal_space_id,
933
1100
  test_source=test_source,
@@ -0,0 +1,174 @@
1
+ """Versioned working notes; one graph-backed head per space/project/Agent.
2
+
3
+ The property-dependent SET acquires Neo4j's write lock before checking the
4
+ revision. This protects against concurrent hosts, not just one Python process.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+
10
+ from fastapi import HTTPException
11
+
12
+ from .project_agent_access import authorize_project_agent
13
+ from .project_agent_memory_models import (
14
+ ProjectAgentMemoryRecord,
15
+ ProjectAgentMemoryView,
16
+ ProjectAgentMemoryWrite,
17
+ )
18
+ from .provider_values import now_utc, stable_uuid
19
+
20
+
21
+ class StoreProjectAgentMemory:
22
+ async def get_project_agent_memory(
23
+ self, actor, personal_space_id, personal_project_id, agent_id, *, limit=1,
24
+ ) -> ProjectAgentMemoryView:
25
+ self._require_personal()
26
+ space = await self.authorize(actor, personal_space_id, 'reader')
27
+ await authorize_project_agent(
28
+ self, actor, space, personal_project_id, agent_id, require_memory=True,
29
+ )
30
+ records, _, _ = await self.runtime.driver.execute_query(
31
+ '''
32
+ MATCH (head:FuliProjectAgentMemory {id: $memory_id})-
33
+ [:HAS_MEMORY_CHECKPOINT]->(checkpoint:FuliProjectAgentMemoryCheckpoint)
34
+ RETURN checkpoint.record_json AS record_json
35
+ ORDER BY checkpoint.revision DESC LIMIT $limit
36
+ ''',
37
+ memory_id=self._agent_memory_id(
38
+ personal_space_id, personal_project_id, agent_id,
39
+ ),
40
+ limit=max(1, min(limit, 10)),
41
+ routing_='r',
42
+ )
43
+ history = [
44
+ ProjectAgentMemoryRecord.model_validate_json(row['record_json'])
45
+ for row in records
46
+ ]
47
+ current = history[0] if history else None
48
+ return ProjectAgentMemoryView(
49
+ personal_space_id=personal_space_id,
50
+ personal_project_id=personal_project_id,
51
+ agent_id=agent_id,
52
+ revision=current.revision if current else 0,
53
+ current=current,
54
+ history=history,
55
+ )
56
+
57
+ async def write_project_agent_memory(
58
+ self, actor, request: ProjectAgentMemoryWrite,
59
+ ) -> ProjectAgentMemoryRecord:
60
+ self._require_personal()
61
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
62
+ agent = await authorize_project_agent(
63
+ self, actor, space, request.personal_project_id, request.agent_id,
64
+ require_active=True, require_memory=True,
65
+ )
66
+ allowed_clients = json.loads(agent['profile_json']).get('allowed_clients', [])
67
+ if request.source_application not in allowed_clients:
68
+ raise HTTPException(status_code=403, detail='Agent is not allowed in this client')
69
+ if request.task_id:
70
+ task_rows, _, _ = await self.runtime.driver.execute_query(
71
+ '''
72
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
73
+ [:HAS_PROJECT_AGENT_TASK]->(task:FuliProjectAgentTask {
74
+ task_id: $task_id, personal_project_id: $project_id
75
+ })-[:HAS_PARTICIPANT]->(agent:FuliProjectAgent {agent_id: $agent_id})
76
+ MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->(agent)
77
+ RETURN task.task_id AS task_id
78
+ ''',
79
+ space_id=request.personal_space_id,
80
+ project_id=request.personal_project_id,
81
+ agent_id=request.agent_id,
82
+ task_id=request.task_id,
83
+ routing_='r',
84
+ )
85
+ if not task_rows:
86
+ raise HTTPException(status_code=404, detail='Agent memory task not found')
87
+ memory_id = self._agent_memory_id(
88
+ request.personal_space_id, request.personal_project_id, request.agent_id,
89
+ )
90
+ checkpoint_id = stable_uuid(memory_id, request.idempotency_key)
91
+ payload = request.model_dump(mode='json', exclude={
92
+ 'source_application', 'source_session_id',
93
+ })
94
+ payload_hash = hashlib.sha256(json.dumps(
95
+ payload, sort_keys=True, ensure_ascii=False, separators=(',', ':'),
96
+ ).encode()).hexdigest()
97
+ checkpoint = ProjectAgentMemoryRecord(
98
+ checkpoint_id=checkpoint_id,
99
+ personal_space_id=request.personal_space_id,
100
+ personal_project_id=request.personal_project_id,
101
+ agent_id=request.agent_id,
102
+ revision=request.expected_revision + 1,
103
+ memory=request.memory,
104
+ source_application=request.source_application,
105
+ source_session_id=request.source_session_id,
106
+ task_id=request.task_id,
107
+ created_at=now_utc(),
108
+ )
109
+ records, _, _ = await self.runtime.driver.execute_query(
110
+ '''
111
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
112
+ [:HAS_PROJECT_AGENT_IDENTITY]->(agent:FuliProjectAgent {
113
+ agent_id: $agent_id, status: 'active'
114
+ })
115
+ MATCH (space)-[:CONTAINS_PROJECT]->
116
+ (project:FuliPersonalProject {project_id: $project_id})-
117
+ [:HAS_PROJECT_AGENT_ASSIGNMENT]->
118
+ (:FuliProjectAgentAssignment {status: 'active'})-[:ASSIGNED_AGENT]->(agent)
119
+ WHERE coalesce(agent.memory_scope, 'reviewed_agent') <> 'task_only'
120
+ WITH DISTINCT space, project, agent
121
+ MERGE (head:FuliProjectAgentMemory {id: $memory_id})
122
+ ON CREATE SET head.revision = 0,
123
+ head.personal_space_id = $space_id,
124
+ head.personal_project_id = $project_id,
125
+ head.agent_id = $agent_id
126
+ SET head.write_serial = coalesce(head.write_serial, 0) + 1
127
+ MERGE (agent)-[:HAS_WORKING_MEMORY]->(head)
128
+ MERGE (project)-[:HAS_AGENT_WORKING_MEMORY]->(head)
129
+ WITH head
130
+ OPTIONAL MATCH (head)-[:HAS_MEMORY_CHECKPOINT]->
131
+ (existing:FuliProjectAgentMemoryCheckpoint {id: $checkpoint_id})
132
+ WITH head, existing,
133
+ existing IS NULL AND head.revision = $expected_revision AS can_write
134
+ FOREACH (ignored IN CASE WHEN can_write THEN [1] ELSE [] END |
135
+ CREATE (checkpoint:FuliProjectAgentMemoryCheckpoint {
136
+ id: $checkpoint_id, revision: $revision,
137
+ payload_hash: $payload_hash, record_json: $record_json
138
+ })
139
+ CREATE (head)-[:HAS_MEMORY_CHECKPOINT]->(checkpoint)
140
+ SET head.revision = $revision, head.updated_at = $created_at
141
+ )
142
+ WITH head
143
+ OPTIONAL MATCH (head)-[:HAS_MEMORY_CHECKPOINT]->
144
+ (saved:FuliProjectAgentMemoryCheckpoint {id: $checkpoint_id})
145
+ RETURN saved.record_json AS record_json, saved.payload_hash AS payload_hash,
146
+ head.revision AS current_revision
147
+ ''',
148
+ space_id=request.personal_space_id,
149
+ project_id=request.personal_project_id,
150
+ agent_id=request.agent_id,
151
+ memory_id=memory_id,
152
+ checkpoint_id=checkpoint_id,
153
+ expected_revision=request.expected_revision,
154
+ revision=checkpoint.revision,
155
+ payload_hash=payload_hash,
156
+ record_json=checkpoint.model_dump_json(),
157
+ created_at=checkpoint.created_at,
158
+ )
159
+ if not records:
160
+ raise HTTPException(status_code=409, detail='Agent assignment is no longer active')
161
+ row = records[0]
162
+ if not row.get('record_json'):
163
+ raise HTTPException(
164
+ status_code=409,
165
+ detail='Agent memory changed; reload its latest revision before merging notes',
166
+ )
167
+ if row['payload_hash'] != payload_hash:
168
+ raise HTTPException(status_code=409, detail='memory idempotency key has different input')
169
+ return ProjectAgentMemoryRecord.model_validate_json(row['record_json'])
170
+
171
+ def _agent_memory_id(self, space_id, project_id, agent_id):
172
+ return stable_uuid(
173
+ self.settings.provider_id, space_id, project_id, agent_id, 'working-memory',
174
+ )