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
@@ -1,25 +1,15 @@
1
1
  import json
2
2
  from collections import defaultdict
3
3
  from datetime import date
4
-
5
4
  from fastapi import HTTPException
6
-
7
5
  from .personal_project_access import authorize_personal_project
8
6
  from .project_agent_access import authorize_project_agent
9
- from .project_agent_executor_models import (
10
- ProjectAgentExecutorActualReport,
11
- ProjectAgentExecutorOutcomeEvidenceCreate,
12
- )
13
- from .project_agent_models import (
14
- ProjectAgentModelStrategy,
15
- ProjectAgentProfile,
16
- )
7
+ from .project_agent_models import ProjectAgentModelStrategy, ProjectAgentProfile
17
8
  from .project_agent_task_models import (
18
9
  ProjectAgentActivityDay,
19
10
  ProjectAgentActivityResult,
20
11
  ProjectAgentActivityTask,
21
12
  ProjectAgentRoutingDecisionRecord,
22
- ProjectAgentTaskActivityCreate,
23
13
  ProjectAgentTaskRecord,
24
14
  ProjectAgentTaskRouteResult,
25
15
  ProjectAgentTaskSubmit,
@@ -27,31 +17,32 @@ from .project_agent_task_models import (
27
17
  from .provider_values import native_datetime, now_utc, stable_uuid
28
18
  from .store_project_agents import SYSTEM_COORDINATOR_AGENT_ID
29
19
  from .store_project_agent_task_reads import StoreProjectAgentTaskReads
20
+ from .store_project_agent_task_persistence import StoreProjectAgentTaskPersistence
30
21
  from .store_project_agent_task_recruitment import StoreProjectAgentTaskRecruitment
31
22
  from .store_project_agent_task_staffing import StoreProjectAgentTaskStaffing
32
-
33
-
34
- TERMINAL_TASK_STATUSES = {'completed', 'failed', 'cancelled'}
35
-
36
-
23
+ from .store_project_agent_coordination_policy import StoreProjectAgentCoordinationPolicy
24
+ from .store_project_agent_task_activity import StoreProjectAgentTaskActivity, TERMINAL_TASK_STATUSES
25
+ from .store_transactions import TransactionQueryDriver, query_store_transaction
37
26
  class StoreProjectAgentTasks(
27
+ StoreProjectAgentTaskActivity,
28
+ StoreProjectAgentCoordinationPolicy,
38
29
  StoreProjectAgentTaskRecruitment,
39
30
  StoreProjectAgentTaskReads,
40
- # Keep the pre-existing base order ahead of the extracted implementation.
31
+ StoreProjectAgentTaskPersistence,
41
32
  StoreProjectAgentTaskStaffing,
42
33
  ):
43
34
  """Durable task, routing, recruitment, and verified activity control plane.
44
35
 
45
- This is deliberately not a worker or scheduler. Registered clients submit
46
- tasks and append truthful state events to the same persistent Agent/task
47
- history. No task is shown as running until a client records that event.
36
+ Clients append real state events; this control plane does not run workers.
48
37
  """
49
-
50
38
  async def submit_project_agent_task(
51
39
  self,
52
40
  actor: dict,
53
41
  request: ProjectAgentTaskSubmit,
54
42
  ) -> ProjectAgentTaskRouteResult:
43
+ if not isinstance(self.runtime.driver, TransactionQueryDriver):
44
+ async with query_store_transaction(self) as scoped:
45
+ return await scoped.submit_project_agent_task(actor, request)
55
46
  self._require_personal()
56
47
  space = await self.authorize(actor, request.personal_space_id, 'maintainer')
57
48
  await authorize_personal_project(
@@ -75,11 +66,32 @@ class StoreProjectAgentTasks(
75
66
  status_code=409,
76
67
  detail='task idempotency key was used with different input',
77
68
  )
69
+ await self._repair_task_persistence(request, raw)
70
+ existing = await self._find_task_row(
71
+ request.personal_space_id,
72
+ task_id,
73
+ )
78
74
  task = self._task_from_row(existing)
79
- recruitment = await self._task_recruitment(
75
+ recruitments = await self._task_recruitments(
80
76
  request.personal_space_id,
81
77
  task.task_id,
82
78
  )
79
+ if await self._recover_recruitment_lifecycle(
80
+ actor,
81
+ request,
82
+ task,
83
+ recruitments,
84
+ ):
85
+ existing = await self._find_task_row(
86
+ request.personal_space_id,
87
+ task_id,
88
+ )
89
+ task = self._task_from_row(existing)
90
+ recruitments = await self._task_recruitments(
91
+ request.personal_space_id,
92
+ task.task_id,
93
+ )
94
+ recruitment = recruitments[0] if recruitments else None
83
95
  assigned_agent = await self._route_assigned_agent(
84
96
  actor,
85
97
  request,
@@ -89,13 +101,9 @@ class StoreProjectAgentTasks(
89
101
  task,
90
102
  recruitment,
91
103
  assigned_agent=assigned_agent,
104
+ recruitments=recruitments,
92
105
  )
93
106
 
94
- coordinator = await self.ensure_system_project_coordinator(
95
- actor,
96
- request.personal_space_id,
97
- )
98
- coordinator_agent_id = request.coordinator_agent_id or coordinator.agent_id
99
107
  if request.coordinator_agent_id:
100
108
  await authorize_project_agent(
101
109
  self,
@@ -158,18 +166,128 @@ class StoreProjectAgentTasks(
158
166
  'qualified durable Agent candidates'
159
167
  )
160
168
 
169
+ parallel_recruitment_slots = (
170
+ await self._automatic_parallel_recruitment_slots(
171
+ actor,
172
+ request,
173
+ lead=lead,
174
+ participants=participants,
175
+ routing_reason=reason,
176
+ )
177
+ )
178
+ projected_participants = await self._parallel_plan_participant_projection(
179
+ actor,
180
+ request,
181
+ lead=lead,
182
+ participants=participants,
183
+ routing_reason=reason,
184
+ recruitment_slots=parallel_recruitment_slots,
185
+ )
186
+ self._verify_parallel_plan(request.parallel_plan, projected_participants)
187
+
188
+ will_open_recruitment = (
189
+ request.staffing_intent != 'unassigned'
190
+ and reason != 'manual_agent_selection'
191
+ and (
192
+ bool(parallel_recruitment_slots) or not lead
193
+ or request.staffing_intent in {'new_durable', 'temporary'}
194
+ )
195
+ )
196
+ if will_open_recruitment:
197
+ _, recruitment_profile = self._normalized_recruitment_profile(request)
198
+ self._require_agent_client_allowed(
199
+ recruitment_profile,
200
+ request.source_application,
201
+ )
202
+
203
+ coordinator = await self.ensure_system_project_coordinator(
204
+ actor,
205
+ request.personal_space_id,
206
+ )
207
+ coordinator_agent_id = request.coordinator_agent_id or coordinator.agent_id
208
+
161
209
  model_strategy, model_source = self._effective_model_strategy(
162
210
  request,
163
211
  lead,
164
212
  coordinator.profile.default_model_strategy,
165
213
  )
166
214
  recruitment = None
215
+ recruitments = []
167
216
  routing_outcome = 'assigned_existing' if lead else 'unassigned'
168
217
  task_status = 'queued' if lead else 'blocked'
169
218
  if request.staffing_intent == 'unassigned':
170
219
  reason = 'explicit_unassigned'
171
220
  routing_outcome = 'unassigned'
172
221
  task_status = 'blocked'
222
+ elif reason == 'manual_agent_selection':
223
+ routing_outcome = 'unassigned'
224
+ task_status = 'blocked'
225
+ elif parallel_recruitment_slots:
226
+ recruited_count = 0
227
+ recruited_lead = False
228
+ for slot in parallel_recruitment_slots:
229
+ slot_recruitment, recruited = await self._open_recruitment(
230
+ actor,
231
+ space,
232
+ request,
233
+ task_id,
234
+ coordinator_agent_id,
235
+ reason,
236
+ participant_role=slot['participant_role'],
237
+ recruitment_slot=slot['recruitment_slot'],
238
+ )
239
+ if slot_recruitment:
240
+ recruitments.append(slot_recruitment)
241
+ if slot['participant_role'] == 'lead':
242
+ recruitment = slot_recruitment
243
+ if not recruited:
244
+ continue
245
+
246
+ recruited_count += 1
247
+ participant = {
248
+ 'agent_id': recruited['agent_id'],
249
+ 'role': slot['participant_role'],
250
+ 'assignment_summary': recruited.get('responsibility'),
251
+ }
252
+ if not any(
253
+ item['agent_id'] == recruited['agent_id']
254
+ for item in participants
255
+ ):
256
+ if slot['participant_role'] == 'lead':
257
+ participants.insert(0, participant)
258
+ else:
259
+ participants.append(participant)
260
+ if slot['participant_role'] == 'lead':
261
+ lead = recruited
262
+ recruited_lead = True
263
+
264
+ if recruitment is None and recruitments:
265
+ recruitment = recruitments[0]
266
+ if recruited_count == len(parallel_recruitment_slots):
267
+ routing_outcome = 'recruited'
268
+ task_status = 'queued'
269
+ match_basis = [
270
+ *match_basis,
271
+ 'parallel automatic recruitment: filled '
272
+ f'{recruited_count} missing participant slot(s)',
273
+ 'HR recruitment recorded before Agent creation',
274
+ ]
275
+ if recruited_lead:
276
+ model_strategy, model_source = self._effective_model_strategy(
277
+ request,
278
+ lead,
279
+ coordinator.profile.default_model_strategy,
280
+ )
281
+ elif any(
282
+ item.status == 'awaiting_confirmation'
283
+ for item in recruitments
284
+ ):
285
+ routing_outcome = 'recruitment_required'
286
+ task_status = 'awaiting_recruitment'
287
+ else:
288
+ routing_outcome = 'blocked'
289
+ task_status = 'blocked'
290
+ reason = 'hr_unavailable'
173
291
  elif not lead or request.staffing_intent in {'new_durable', 'temporary'}:
174
292
  recruitment, recruited = await self._open_recruitment(
175
293
  actor,
@@ -179,6 +297,8 @@ class StoreProjectAgentTasks(
179
297
  coordinator_agent_id,
180
298
  reason,
181
299
  )
300
+ if recruitment:
301
+ recruitments.append(recruitment)
182
302
  if recruited:
183
303
  lead = recruited
184
304
  if not any(
@@ -283,6 +403,7 @@ class StoreProjectAgentTasks(
283
403
  participants=participants,
284
404
  decision=decision,
285
405
  recruitment=recruitment,
406
+ recruitments=recruitments,
286
407
  executor_decision=executor_decision,
287
408
  created_at=now,
288
409
  )
@@ -300,6 +421,7 @@ class StoreProjectAgentTasks(
300
421
  task,
301
422
  recruitment,
302
423
  assigned_agent=assigned_agent,
424
+ recruitments=recruitments,
303
425
  )
304
426
 
305
427
  async def get_project_agent_task(
@@ -307,19 +429,33 @@ class StoreProjectAgentTasks(
307
429
  actor: dict,
308
430
  personal_space_id: str,
309
431
  task_id: str,
432
+ *,
433
+ personal_project_id: str | None = None,
310
434
  ) -> ProjectAgentTaskRecord:
311
435
  self._require_personal()
312
436
  space = await self.authorize(actor, personal_space_id, 'reader')
313
- row = await self._find_task_row(personal_space_id, task_id)
437
+ if personal_project_id:
438
+ await authorize_personal_project(
439
+ self,
440
+ actor,
441
+ space,
442
+ personal_project_id,
443
+ )
444
+ row = await self._find_task_row(
445
+ personal_space_id,
446
+ task_id,
447
+ personal_project_id,
448
+ )
314
449
  if not row:
315
450
  raise HTTPException(status_code=404, detail='project Agent task not found')
316
451
  task = dict(row['task'])
317
- await authorize_personal_project(
318
- self,
319
- actor,
320
- space,
321
- task['personal_project_id'],
322
- )
452
+ if not personal_project_id:
453
+ await authorize_personal_project(
454
+ self,
455
+ actor,
456
+ space,
457
+ task['personal_project_id'],
458
+ )
323
459
  return self._task_from_row(row)
324
460
 
325
461
  async def list_project_agent_tasks(
@@ -362,349 +498,6 @@ class StoreProjectAgentTasks(
362
498
  )
363
499
  return [self._task_from_row(row) for row in records]
364
500
 
365
- async def record_project_agent_task_activity(
366
- self,
367
- actor: dict,
368
- request: ProjectAgentTaskActivityCreate,
369
- ) -> ProjectAgentTaskRecord:
370
- self._require_personal()
371
- space = await self.authorize(actor, request.personal_space_id, 'maintainer')
372
- await authorize_personal_project(
373
- self,
374
- actor,
375
- space,
376
- request.personal_project_id,
377
- )
378
- row = await self._find_task_row(request.personal_space_id, request.task_id)
379
- if not row:
380
- raise HTTPException(status_code=404, detail='project Agent task not found')
381
- raw_task = dict(row['task'])
382
- if raw_task['personal_project_id'] != request.personal_project_id:
383
- raise HTTPException(status_code=404, detail='project Agent task not found')
384
- event_id = stable_uuid(
385
- self.settings.provider_id,
386
- request.personal_space_id,
387
- 'project-agent-task-event',
388
- request.task_id,
389
- request.idempotency_key,
390
- )
391
- payload_hash = self._payload_hash(request)
392
- existing_event = next(
393
- (
394
- dict(value)
395
- for value in (row.get('event_rows') or [])
396
- if value
397
- and (value.get('event_id') or value.get('id')) == event_id
398
- ),
399
- None,
400
- )
401
- if existing_event:
402
- if existing_event.get('payload_hash') != payload_hash:
403
- raise HTTPException(
404
- status_code=409,
405
- detail=(
406
- 'task event idempotency key was used with different input'
407
- ),
408
- )
409
- return self._task_from_row(row)
410
- self._validate_task_transition(
411
- raw_task.get('status'),
412
- request.status,
413
- has_actual_executor=bool(request.actual_executor_id),
414
- )
415
- expected_revision = getattr(request, 'expected_revision', None)
416
- if expected_revision is None:
417
- expected_revision = raw_task.get('revision', 0)
418
- if request.agent_id:
419
- participant = next(
420
- (
421
- item for item in self._participant_maps(row)
422
- if item['agent_id'] == request.agent_id
423
- ),
424
- None,
425
- )
426
- if not participant:
427
- raise HTTPException(
428
- status_code=409,
429
- detail='Agent is not assigned to this task',
430
- )
431
- if participant.get('profile_json'):
432
- self._require_agent_client_allowed(
433
- {
434
- 'agent_id': request.agent_id,
435
- 'profile': ProjectAgentProfile.model_validate_json(
436
- participant['profile_json']
437
- ),
438
- },
439
- request.source_application,
440
- )
441
- now = now_utc()
442
- terminal_at = now if request.status in TERMINAL_TASK_STATUSES else None
443
- actual_executor_id = getattr(request, 'actual_executor_id', None)
444
- matched_executor_rule_id = getattr(request, 'matched_executor_rule_id', None)
445
- executor_selection_reason = getattr(request, 'executor_selection_reason', None)
446
- executor_fallback_reason = getattr(request, 'executor_fallback_reason', None)
447
- executor_blocked_reason = getattr(request, 'executor_blocked_reason', None)
448
- worker_id = getattr(request, 'worker_id', None)
449
- worker_label = getattr(request, 'worker_label', None)
450
- worker_occupation_emoji = getattr(
451
- request,
452
- 'worker_occupation_emoji',
453
- None,
454
- )
455
- worker_status = getattr(request, 'worker_status', None)
456
- actual_recorder = None
457
- actual_report = None
458
- if actual_executor_id:
459
- actual_recorder = getattr(
460
- self,
461
- 'record_project_agent_executor_actual',
462
- None,
463
- )
464
- if not callable(actual_recorder):
465
- raise HTTPException(
466
- status_code=503,
467
- detail='executor evidence control plane is unavailable',
468
- )
469
- strategy = (
470
- ProjectAgentModelStrategy.model_validate_json(
471
- raw_task['effective_model_strategy_json']
472
- )
473
- if raw_task.get('effective_model_strategy_json')
474
- else ProjectAgentModelStrategy()
475
- )
476
- actual_report = ProjectAgentExecutorActualReport(
477
- personal_space_id=request.personal_space_id,
478
- personal_project_id=request.personal_project_id,
479
- task_id=request.task_id,
480
- run_id=event_id,
481
- agent_id=request.agent_id,
482
- executor_id=actual_executor_id,
483
- provider=request.actual_model_provider,
484
- model=request.actual_model,
485
- model_strategy=strategy,
486
- model_strategy_source=(
487
- raw_task.get('model_strategy_source') or 'coordinator'
488
- ),
489
- matched_rule_id=matched_executor_rule_id,
490
- fallback_reason=executor_fallback_reason,
491
- idempotency_key=f'actual:{event_id}',
492
- occurred_at=now,
493
- source_application=request.source_application,
494
- source_session_id=request.source_session_id,
495
- )
496
- records, _, _ = await self.runtime.driver.execute_query(
497
- '''
498
- MATCH (space:FuliSpace {
499
- id: $personal_space_id, kind: 'personal'
500
- })-[:HAS_PROJECT_AGENT_TASK]->
501
- (task:FuliProjectAgentTask {
502
- personal_space_id: $personal_space_id,
503
- task_id: $task_id
504
- })
505
- WHERE task.revision = $expected_revision
506
- OR EXISTS {
507
- MATCH (task)-[:HAS_TASK_EVENT]->
508
- (:FuliProjectAgentTaskEvent {id: $event_id})
509
- }
510
- OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
511
- (agent:FuliProjectAgent {agent_id: $agent_id})
512
- WHERE $agent_id IS NULL OR EXISTS {
513
- MATCH (task)-[:HAS_PARTICIPANT]->(agent)
514
- }
515
- MERGE (event:FuliProjectAgentTaskEvent {id: $event_id})
516
- ON CREATE SET event.event_id = $event_id,
517
- event.task_id = $task_id,
518
- event.agent_id = $agent_id,
519
- event.status = $status,
520
- event.actor_kind = $actor_kind,
521
- event.summary = $summary,
522
- event.payload_hash = $payload_hash,
523
- event.source_application = $source_application,
524
- event.source_session_id = $source_session_id,
525
- event.actual_model_provider = $actual_model_provider,
526
- event.actual_model = $actual_model,
527
- event.actual_executor_id = $actual_executor_id,
528
- event.matched_executor_rule_id =
529
- $matched_executor_rule_id,
530
- event.executor_selection_reason =
531
- $executor_selection_reason,
532
- event.executor_fallback_reason =
533
- $executor_fallback_reason,
534
- event.executor_blocked_reason =
535
- $executor_blocked_reason,
536
- event.worker_id = $worker_id,
537
- event.worker_label = $worker_label,
538
- event.worker_occupation_emoji = $worker_occupation_emoji,
539
- event.worker_status = $worker_status,
540
- event.activity_date = CASE
541
- WHEN $terminal_at IS NULL THEN null
542
- ELSE date($terminal_at) END,
543
- event.created_at = $updated_at
544
- WITH task, event, agent,
545
- event.payload_hash = $payload_hash AS same_payload,
546
- task.revision = $expected_revision AS applied_transition
547
- FOREACH (_ IN CASE
548
- WHEN same_payload AND applied_transition
549
- THEN [1] ELSE [] END |
550
- MERGE (task)-[:HAS_TASK_EVENT]->(event)
551
- )
552
- FOREACH (_ IN CASE
553
- WHEN same_payload AND applied_transition AND agent IS NOT NULL
554
- THEN [1] ELSE [] END |
555
- MERGE (event)-[:EVENT_AGENT]->(agent)
556
- )
557
- FOREACH (_ IN CASE
558
- WHEN same_payload AND applied_transition
559
- THEN [1] ELSE [] END |
560
- SET task.status = $status,
561
- task.result_summary = CASE
562
- WHEN $status = 'completed' THEN $summary
563
- ELSE task.result_summary END,
564
- task.failure_reason = CASE
565
- WHEN $status = 'failed' THEN $summary
566
- ELSE task.failure_reason END,
567
- task.actual_executor_id = coalesce(
568
- $actual_executor_id, task.actual_executor_id),
569
- task.actual_model_provider = coalesce(
570
- $actual_model_provider, task.actual_model_provider),
571
- task.actual_model = coalesce($actual_model, task.actual_model),
572
- task.matched_executor_rule_id = coalesce(
573
- $matched_executor_rule_id,
574
- task.matched_executor_rule_id),
575
- task.executor_selection_reason = coalesce(
576
- $executor_selection_reason,
577
- task.executor_selection_reason),
578
- task.executor_fallback_reason = coalesce(
579
- $executor_fallback_reason,
580
- task.executor_fallback_reason),
581
- task.executor_blocked_reason = coalesce(
582
- $executor_blocked_reason,
583
- task.executor_blocked_reason),
584
- task.completed_at = $terminal_at,
585
- task.updated_at = $updated_at,
586
- task.revision = task.revision + 1
587
- )
588
- FOREACH (_ IN CASE
589
- WHEN same_payload AND applied_transition AND agent IS NOT NULL
590
- THEN [1] ELSE [] END |
591
- SET task.lead_agent_id = CASE
592
- WHEN task.lead_agent_id IS NULL AND $status = 'running'
593
- THEN agent.agent_id ELSE task.lead_agent_id END
594
- )
595
- WITH task, event, same_payload, applied_transition
596
- OPTIONAL MATCH (task)-[participant:HAS_PARTICIPANT]->
597
- (participant_agent:FuliProjectAgent)
598
- FOREACH (_ IN CASE
599
- WHEN same_payload
600
- AND applied_transition
601
- AND participant IS NOT NULL
602
- AND NOT (coalesce(participant.status, '') IN
603
- ['completed', 'failed', 'cancelled'])
604
- AND (
605
- $terminal_at IS NOT NULL
606
- OR $agent_id IS NULL
607
- OR participant_agent.agent_id = $agent_id
608
- )
609
- THEN [1] ELSE [] END |
610
- SET participant.status = $status,
611
- participant.updated_at = $updated_at,
612
- participant.ended_at = $terminal_at
613
- )
614
- RETURN DISTINCT task, event, same_payload, applied_transition
615
- ''',
616
- personal_space_id=request.personal_space_id,
617
- task_id=request.task_id,
618
- expected_revision=expected_revision,
619
- event_id=event_id,
620
- agent_id=request.agent_id,
621
- status=request.status,
622
- actor_kind=request.actor_kind,
623
- summary=request.summary,
624
- payload_hash=payload_hash,
625
- source_application=request.source_application,
626
- source_session_id=request.source_session_id,
627
- actual_model_provider=request.actual_model_provider,
628
- actual_model=request.actual_model,
629
- actual_executor_id=actual_executor_id,
630
- matched_executor_rule_id=matched_executor_rule_id,
631
- executor_selection_reason=executor_selection_reason,
632
- executor_fallback_reason=executor_fallback_reason,
633
- executor_blocked_reason=executor_blocked_reason,
634
- worker_id=worker_id,
635
- worker_label=worker_label,
636
- worker_occupation_emoji=worker_occupation_emoji,
637
- worker_status=worker_status,
638
- terminal_at=terminal_at,
639
- updated_at=now,
640
- )
641
- if not records:
642
- raise HTTPException(
643
- status_code=409,
644
- detail='task revision is stale or participant is invalid',
645
- )
646
- if not records[0]['same_payload']:
647
- raise HTTPException(
648
- status_code=409,
649
- detail='task event idempotency key was used with different input',
650
- )
651
- if not records[0].get('applied_transition'):
652
- return await self.get_project_agent_task(
653
- actor,
654
- request.personal_space_id,
655
- request.task_id,
656
- )
657
- if actual_report:
658
- await actual_recorder(actor, actual_report)
659
- evidence_status = (
660
- request.status
661
- if request.status in TERMINAL_TASK_STATUSES
662
- else worker_status
663
- )
664
- if (
665
- evidence_status in TERMINAL_TASK_STATUSES
666
- and actual_executor_id
667
- and request.agent_id
668
- ):
669
- evidence_recorder = getattr(
670
- self,
671
- 'record_project_agent_executor_outcome_evidence',
672
- None,
673
- )
674
- if callable(evidence_recorder):
675
- strategy = (
676
- ProjectAgentModelStrategy.model_validate_json(
677
- raw_task['effective_model_strategy_json']
678
- )
679
- if raw_task.get('effective_model_strategy_json')
680
- else ProjectAgentModelStrategy()
681
- )
682
- await evidence_recorder(
683
- actor,
684
- ProjectAgentExecutorOutcomeEvidenceCreate(
685
- personal_space_id=request.personal_space_id,
686
- personal_project_id=request.personal_project_id,
687
- work_kind=raw_task['work_kind'],
688
- agent_id=request.agent_id,
689
- executor_id=actual_executor_id,
690
- task_id=request.task_id,
691
- run_id=event_id,
692
- model_strategy=strategy,
693
- evidence_kind='terminal_outcome',
694
- source='system_terminal',
695
- terminal_outcome=evidence_status,
696
- reference_ids=[event_id],
697
- idempotency_key=f'terminal:{event_id}',
698
- occurred_at=now,
699
- ),
700
- )
701
- await self._archive_finished_temporary_agent(request, raw_task, now)
702
- return await self.get_project_agent_task(
703
- actor,
704
- request.personal_space_id,
705
- request.task_id,
706
- )
707
-
708
501
  async def get_project_agent_activity(
709
502
  self,
710
503
  actor: dict,
@@ -712,9 +505,18 @@ class StoreProjectAgentTasks(
712
505
  agent_id: str,
713
506
  from_date: date,
714
507
  to_date: date,
508
+ *,
509
+ personal_project_id: str | None = None,
715
510
  ) -> ProjectAgentActivityResult:
716
511
  self._require_personal()
717
- await self.authorize(actor, personal_space_id, 'reader')
512
+ space = await self.authorize(actor, personal_space_id, 'reader')
513
+ if personal_project_id:
514
+ await authorize_personal_project(
515
+ self,
516
+ actor,
517
+ space,
518
+ personal_project_id,
519
+ )
718
520
  if from_date > to_date:
719
521
  raise HTTPException(status_code=422, detail='from date must not exceed to date')
720
522
  records, _, _ = await self.runtime.driver.execute_query(
@@ -726,12 +528,18 @@ class StoreProjectAgentTasks(
726
528
  (task:FuliProjectAgentTask)-[:HAS_TASK_EVENT]->
727
529
  (event:FuliProjectAgentTaskEvent)-[:EVENT_AGENT]->(agent)
728
530
  WHERE event.status IN ['completed', 'failed', 'cancelled']
531
+ AND ($personal_project_id IS NULL
532
+ OR task.personal_project_id = $personal_project_id)
729
533
  AND event.activity_date >= date($from_date)
730
534
  AND event.activity_date <= date($to_date)
731
- RETURN event, task.title AS title
535
+ WITH task, event, agent
536
+ OPTIONAL MATCH (task)-[:HAS_TASK_EVENT]->(evidence_event:FuliProjectAgentTaskEvent)-[:EVENT_AGENT]->(agent)
537
+ WHERE evidence_event.created_at <= event.created_at
538
+ RETURN event, task.title AS title, collect(evidence_event) AS evidence_events
732
539
  ORDER BY event.created_at DESC, event.event_id
733
540
  ''',
734
541
  personal_space_id=personal_space_id,
542
+ personal_project_id=personal_project_id,
735
543
  agent_id=agent_id,
736
544
  from_date=from_date.isoformat(),
737
545
  to_date=to_date.isoformat(),
@@ -739,7 +547,9 @@ class StoreProjectAgentTasks(
739
547
  )
740
548
  grouped = defaultdict(list)
741
549
  for row in records:
742
- event = dict(row['event'])
550
+ event = self._event_with_latest_execution_evidence(
551
+ row['event'], row.get('evidence_events') or []
552
+ )
743
553
  occurred_at = native_datetime(event['created_at'])
744
554
  grouped[occurred_at.date()].append(ProjectAgentActivityTask(
745
555
  task_id=event['task_id'],
@@ -777,7 +587,6 @@ class StoreProjectAgentTasks(
777
587
 
778
588
  async def _route_assigned_agent(self, actor, request, task):
779
589
  """Project the selected durable identity into the route response."""
780
-
781
590
  agent_id = getattr(task, 'lead_agent_id', None)
782
591
  getter = getattr(self, 'get_project_agent', None)
783
592
  if not agent_id or not callable(getter):
@@ -891,7 +700,7 @@ class StoreProjectAgentTasks(
891
700
  task_id=task_id,
892
701
  agent_id=selected['agent_id'],
893
702
  work_kind=request.work_kind,
894
- required_capabilities=request.required_capabilities,
703
+ required_capabilities=request.executor_capability_hints,
895
704
  model_strategy=model_strategy,
896
705
  task_override=(
897
706
  getattr(request, 'executor_policy_override', None)
@@ -912,9 +721,6 @@ class StoreProjectAgentTasks(
912
721
  return {}
913
722
  raw = decision.model_dump(mode='json') if hasattr(decision, 'model_dump') else dict(decision)
914
723
  aliases = {
915
- # The routing decision audit field is JSON data. Keep the
916
- # provider-neutral snapshot instead of leaking a Pydantic model
917
- # into the parent record (which fails validation in real routing).
918
724
  'executor_decision': raw,
919
725
  'selected_executor_id': raw.get('selected_executor_id') or raw.get('executor_id'),
920
726
  'matched_executor_rule_id': raw.get('matched_rule_id') or raw.get('rule_id'),
@@ -928,26 +734,15 @@ class StoreProjectAgentTasks(
928
734
  if key in ProjectAgentRoutingDecisionRecord.model_fields
929
735
  }
930
736
 
931
- @staticmethod
932
- def _activity_executor_fields(event):
933
- return {
934
- key: value
935
- for key, value in {
936
- 'actual_executor_id': event.get('actual_executor_id'),
937
- 'matched_executor_rule_id': event.get('matched_executor_rule_id'),
938
- 'worker_id': event.get('worker_id'),
939
- 'worker_label': event.get('worker_label'),
940
- 'worker_occupation_emoji': event.get(
941
- 'worker_occupation_emoji'
942
- ),
943
- 'worker_status': event.get('worker_status'),
944
- }.items()
945
- if key in ProjectAgentActivityTask.model_fields
946
- }
737
+ async def _persist_task(self, request, **values):
738
+ # The task must not become visible before all of its planned edges exist.
739
+ async with self.runtime.driver.transaction() as transaction:
740
+ await self._persist_task_transaction(request, transaction, **values)
947
741
 
948
- async def _persist_task(
742
+ async def _persist_task_transaction(
949
743
  self,
950
744
  request,
745
+ transaction,
951
746
  *,
952
747
  task_id,
953
748
  payload_hash,
@@ -967,7 +762,24 @@ class StoreProjectAgentTasks(
967
762
  recruitment,
968
763
  executor_decision,
969
764
  created_at,
765
+ recruitments=None,
970
766
  ):
767
+ linked_recruitments = list(recruitments or [])
768
+ if recruitment and not any(
769
+ item.recruitment_id == recruitment.recruitment_id
770
+ for item in linked_recruitments
771
+ ):
772
+ linked_recruitments.insert(0, recruitment)
773
+ recruitment_links = [
774
+ {
775
+ 'recruitment_id': item.recruitment_id,
776
+ 'is_primary': bool(
777
+ recruitment
778
+ and item.recruitment_id == recruitment.recruitment_id
779
+ ),
780
+ }
781
+ for item in linked_recruitments
782
+ ]
971
783
  decision_raw = decision.model_dump(mode='json')
972
784
  executor_raw = (
973
785
  executor_decision.model_dump(mode='json')
@@ -981,15 +793,15 @@ class StoreProjectAgentTasks(
981
793
  task_id,
982
794
  'created',
983
795
  )
984
- await self.runtime.driver.execute_query(
796
+ result = await transaction.run(
985
797
  '''
986
798
  MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
987
799
  MATCH (space)-[:CONTAINS_PROJECT]->
988
800
  (project:FuliPersonalProject {project_id: $personal_project_id})
989
801
  MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
990
802
  (coordinator:FuliProjectAgent {agent_id: $coordinator_agent_id})
991
- CREATE (task:FuliProjectAgentTask {
992
- id: $task_id,
803
+ MERGE (task:FuliProjectAgentTask {id: $task_id})
804
+ ON CREATE SET task += {
993
805
  task_id: $task_id,
994
806
  personal_space_id: $personal_space_id,
995
807
  personal_project_id: $personal_project_id,
@@ -998,6 +810,7 @@ class StoreProjectAgentTasks(
998
810
  objective: $objective,
999
811
  work_kind: $work_kind,
1000
812
  required_capabilities: $required_capabilities,
813
+ executor_capability_hints: $executor_capability_hints,
1001
814
  duration: $duration,
1002
815
  staffing_intent: $staffing_intent,
1003
816
  status: $status,
@@ -1021,32 +834,39 @@ class StoreProjectAgentTasks(
1021
834
  executor_decision_json: $executor_decision_json,
1022
835
  executor_policy_json: $executor_policy_json,
1023
836
  selected_executor_id: $selected_executor_id,
837
+ participant_plan_json: $participant_plan_json,
838
+ recruitment_plan_json: $recruitment_plan_json,
1024
839
  created_at: $created_at,
1025
840
  updated_at: $created_at
841
+ }
842
+ WITH space, project, coordinator, task
843
+ WHERE task.payload_hash = $payload_hash
844
+ MERGE (decision_node:FuliProjectAgentRoutingDecision {
845
+ id: $decision_id
1026
846
  })
1027
- CREATE (decision_node:FuliProjectAgentRoutingDecision {
1028
- id: $decision_id,
1029
- decision_id: $decision_id,
1030
- task_id: $task_id,
1031
- decision_json: $decision_json,
1032
- created_at: $created_at
1033
- })
1034
- CREATE (event:FuliProjectAgentTaskEvent {
1035
- id: $initial_event_id,
1036
- event_id: $initial_event_id,
1037
- task_id: $task_id,
1038
- status: $status,
1039
- actor_kind: 'system',
1040
- summary: $routing_explanation,
1041
- source_application: $source_application,
1042
- source_session_id: $source_session_id,
1043
- created_at: $created_at
1044
- })
847
+ ON CREATE SET decision_node.decision_id = $decision_id,
848
+ decision_node.task_id = $task_id,
849
+ decision_node.decision_json = $decision_json,
850
+ decision_node.created_at = $created_at
851
+ MERGE (event:FuliProjectAgentTaskEvent {id: $initial_event_id})
852
+ ON CREATE SET event.event_id = $initial_event_id,
853
+ event.task_id = $task_id,
854
+ event.status = $status,
855
+ event.actor_kind = 'system',
856
+ event.summary = $routing_explanation,
857
+ event.source_application = $source_application,
858
+ event.source_session_id = $source_session_id,
859
+ event.created_at = $created_at
1045
860
  MERGE (space)-[:HAS_PROJECT_AGENT_TASK]->(task)
1046
861
  MERGE (project)-[:HAS_PROJECT_AGENT_TASK]->(task)
1047
862
  MERGE (task)-[:COORDINATED_BY]->(coordinator)
1048
863
  MERGE (task)-[:ROUTED_BY]->(decision_node)
1049
864
  MERGE (task)-[:HAS_TASK_EVENT]->(event)
865
+ RETURN true AS payload_matches,
866
+ task.participant_plan_json AS participant_plan_json,
867
+ task.recruitment_plan_json AS recruitment_plan_json,
868
+ task.status AS persisted_status,
869
+ task.created_at AS persisted_created_at
1050
870
  ''',
1051
871
  task_id=task_id,
1052
872
  personal_space_id=request.personal_space_id,
@@ -1056,6 +876,7 @@ class StoreProjectAgentTasks(
1056
876
  objective=request.objective,
1057
877
  work_kind=request.work_kind,
1058
878
  required_capabilities=request.required_capabilities,
879
+ executor_capability_hints=request.executor_capability_hints,
1059
880
  duration=request.duration,
1060
881
  staffing_intent=request.staffing_intent,
1061
882
  status=status,
@@ -1094,51 +915,30 @@ class StoreProjectAgentTasks(
1094
915
  (executor_raw or {}).get('selected_executor_id')
1095
916
  or (executor_raw or {}).get('executor_id')
1096
917
  ),
918
+ participant_plan_json=json.dumps(participants, sort_keys=True),
919
+ recruitment_plan_json=json.dumps(recruitment_links, sort_keys=True),
1097
920
  created_at=created_at,
1098
921
  decision_id=decision.decision_id,
1099
922
  decision_json=json.dumps(decision_raw, sort_keys=True),
1100
923
  initial_event_id=initial_event_id,
1101
924
  )
1102
- for participant in participants:
1103
- await self.runtime.driver.execute_query(
1104
- '''
1105
- MATCH (space:FuliSpace {
1106
- id: $personal_space_id, kind: 'personal'
1107
- })-[:HAS_PROJECT_AGENT_TASK]->
1108
- (task:FuliProjectAgentTask {task_id: $task_id})
1109
- MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
1110
- (agent:FuliProjectAgent {agent_id: $agent_id})
1111
- MERGE (task)-[participant:HAS_PARTICIPANT]->(agent)
1112
- SET participant.role = $role,
1113
- participant.status = $status,
1114
- participant.assignment_summary = $assignment_summary,
1115
- participant.joined_at = $created_at,
1116
- participant.updated_at = $created_at
1117
- ''',
1118
- personal_space_id=request.personal_space_id,
1119
- task_id=task_id,
1120
- agent_id=participant['agent_id'],
1121
- role=participant['role'],
1122
- status=status,
1123
- assignment_summary=participant.get('assignment_summary'),
1124
- created_at=created_at,
1125
- )
1126
- if recruitment:
1127
- await self.runtime.driver.execute_query(
1128
- '''
1129
- MATCH (space:FuliSpace {
1130
- id: $personal_space_id, kind: 'personal'
1131
- })-[:HAS_PROJECT_AGENT_TASK]->
1132
- (task:FuliProjectAgentTask {task_id: $task_id})
1133
- MATCH (space)-[:HAS_PROJECT_AGENT_RECRUITMENT]->
1134
- (recruitment:FuliProjectAgentRecruitment {
1135
- recruitment_id: $recruitment_id
1136
- })
1137
- SET task.recruitment_id = $recruitment_id,
1138
- task.hr_agent_id = recruitment.hr_agent_id
1139
- MERGE (task)-[:TRIGGERED_RECRUITMENT]->(recruitment)
1140
- ''',
1141
- personal_space_id=request.personal_space_id,
1142
- task_id=task_id,
1143
- recruitment_id=recruitment.recruitment_id,
925
+ records = [record async for record in result]
926
+ if not records or not dict(records[0]).get('payload_matches'):
927
+ raise HTTPException(
928
+ status_code=409,
929
+ detail='task idempotency key was used with different input',
1144
930
  )
931
+ persisted = dict(records[0])
932
+ await self._persist_task_links(
933
+ request,
934
+ transaction=transaction,
935
+ task_id=task_id,
936
+ participants=self._task_link_plan(
937
+ persisted.get('participant_plan_json')
938
+ ),
939
+ recruitment_links=self._task_link_plan(
940
+ persisted.get('recruitment_plan_json')
941
+ ),
942
+ status=persisted.get('persisted_status') or status,
943
+ created_at=persisted.get('persisted_created_at') or created_at,
944
+ )