fuli-context 0.7.6 → 0.7.8

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 (150) hide show
  1. package/README.md +69 -13
  2. package/README.zh-CN.md +60 -14
  3. package/dist/web/assets/{AboutPage-sLV9WChi.js → AboutPage-9CVX91tr.js} +1 -1
  4. package/dist/web/assets/{ConnectionsPage-BWn4ywGJ.js → ConnectionsPage-CWbCVFdv.js} +1 -1
  5. package/dist/web/assets/EmployeeRecruitDialog-CQgNpGJ_.js +1 -0
  6. package/dist/web/assets/EmployeeRecruitDialog-CfFlyKSh.css +1 -0
  7. package/dist/web/assets/EmployeeWorkbenchPage-CWg4WJhr.css +1 -0
  8. package/dist/web/assets/EmployeeWorkbenchPage-CwU4qBgD.js +1 -0
  9. package/dist/web/assets/KnowledgeEditDialog-B6TGAcFH.js +1 -0
  10. package/dist/web/assets/{KnowledgeEditDialog-DWUV_NPp.css → KnowledgeEditDialog-iv-dgUeD.css} +1 -1
  11. package/dist/web/assets/{KnowledgeInspector-CRQWOaos.js → KnowledgeInspector-M2pLY0bR.js} +1 -1
  12. package/dist/web/assets/{KnowledgeOrganizerPage-Dfj3vIpU.js → KnowledgeOrganizerPage-DzeAlk9b.js} +1 -1
  13. package/dist/web/assets/KnowledgePage-BJu9w0fF.js +1 -0
  14. package/dist/web/assets/{KnowledgeWorkspace-ChEB5Bwk.js → KnowledgeWorkspace-CCt8rnBb.js} +1 -1
  15. package/dist/web/assets/{OverviewPage-Cd66M0RT.js → OverviewPage-CZswVqui.js} +1 -1
  16. package/dist/web/assets/{PersonalProfilePage-RCFHL0Wz.js → PersonalProfilePage-f1nSF6kx.js} +1 -1
  17. package/dist/web/assets/PersonalProjectsPage-D__eJf0t.js +1 -0
  18. package/dist/web/assets/ProjectAgentsPage-C3vW6wmk.css +1 -0
  19. package/dist/web/assets/ProjectAgentsPage-LrwagMGj.js +11 -0
  20. package/dist/web/assets/{PublicProjectsPage-mstPEjHe.js → PublicProjectsPage-BdC2Qk4n.js} +1 -1
  21. package/dist/web/assets/{ReviewPage-6Y1SmfZy.js → ReviewPage-Cbgl8_Si.js} +1 -1
  22. package/dist/web/assets/SearchableSelect-ERm-k4KI.js +1 -0
  23. package/dist/web/assets/{SettingsPage--LdTMUm7.js → SettingsPage-DWso4x29.js} +1 -1
  24. package/dist/web/assets/{WritingTastePage-CIpKswvs.js → WritingTastePage-CFpCjmIY.js} +1 -1
  25. package/dist/web/assets/catalog-WGLn7ERJ.js +1 -0
  26. package/dist/web/assets/{console-BT1P2Db4.js → console-DQgu0JIA.js} +1 -1
  27. package/dist/web/assets/conversation-launcher-settings-0KtlG_LV.js +1 -0
  28. package/dist/web/assets/{i18n-js6gfc7j.js → i18n-ByRiA3pJ.js} +3 -3
  29. package/dist/web/assets/{identity-CfDfanpa.js → identity-CxdPRMwH.js} +1 -1
  30. package/dist/web/assets/index-JAZe6lfX.js +2 -0
  31. package/dist/web/assets/index-wcXzzggp.css +1 -0
  32. package/dist/web/assets/{model-DJkLAJsg.js → model-Db4-KefQ.js} +1 -1
  33. package/dist/web/assets/{useApi-CROJJdhE-BdSti7Y2.js → useApi-CROJJdhE-CePMjc08.js} +1 -1
  34. package/dist/web/assets/{vue-router-Kk67EaCP.js → vue-router-CzX-hn3P.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_executor_models.py +35 -7
  44. package/graph-provider/fuli_graph/project_agent_memory_models.py +79 -0
  45. package/graph-provider/fuli_graph/project_agent_memory_routes.py +46 -0
  46. package/graph-provider/fuli_graph/project_agent_models.py +9 -0
  47. package/graph-provider/fuli_graph/project_agent_recruitment_values.py +48 -0
  48. package/graph-provider/fuli_graph/project_agent_routes.py +21 -1
  49. package/graph-provider/fuli_graph/project_agent_task_models.py +217 -29
  50. package/graph-provider/fuli_graph/runtime.py +37 -1
  51. package/graph-provider/fuli_graph/store.py +6 -0
  52. package/graph-provider/fuli_graph/store_project_agent_context.py +65 -0
  53. package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +175 -11
  54. package/graph-provider/fuli_graph/store_project_agent_executor_routing.py +33 -10
  55. package/graph-provider/fuli_graph/store_project_agent_executors.py +181 -14
  56. package/graph-provider/fuli_graph/store_project_agent_memory.py +174 -0
  57. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +423 -0
  58. package/graph-provider/fuli_graph/store_project_agent_task_persistence.py +152 -0
  59. package/graph-provider/fuli_graph/store_project_agent_task_reads.py +187 -21
  60. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +748 -162
  61. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +230 -18
  62. package/graph-provider/fuli_graph/store_project_agent_tasks.py +261 -468
  63. package/graph-provider/fuli_graph/store_project_agents.py +214 -46
  64. package/graph-provider/fuli_graph/store_task_contexts.py +217 -0
  65. package/graph-provider/fuli_graph/store_transactions.py +40 -0
  66. package/graph-provider/fuli_graph/task_context_models.py +50 -0
  67. package/graph-provider/fuli_graph/task_context_routes.py +31 -0
  68. package/graph-provider/fuli_graph/workflow_observation_models.py +1 -1
  69. package/graph-provider/pyproject.toml +1 -1
  70. package/npm-shrinkwrap.json +8 -8
  71. package/package.json +5 -2
  72. package/skills/capturing-session-knowledge/SKILL.md +18 -6
  73. package/src/adaptive-runtime/executor-pool.js +46 -5
  74. package/src/adaptive-runtime/graph-services.js +10 -3
  75. package/src/adaptive-runtime/lease-client.js +66 -14
  76. package/src/adaptive-runtime/runtime-broker.js +24 -9
  77. package/src/agent-tools/employee-definitions.js +43 -0
  78. package/src/agent-tools/graph-definitions.js +25 -6
  79. package/src/agent-tools/graph-handlers.js +11 -0
  80. package/src/agent-tools/project-agent-definitions.js +35 -4
  81. package/src/agent-tools/project-agent-memory-definitions.js +36 -0
  82. package/src/agent-tools.js +6 -2
  83. package/src/agents/codex/lifecycle-hook.js +107 -0
  84. package/src/agents/codex/lifecycle-hooks.js +231 -0
  85. package/src/agents/cursor/lifecycle-hook.js +133 -0
  86. package/src/agents/cursor/lifecycle-hooks.js +50 -0
  87. package/src/agents/lifecycle-message.js +19 -0
  88. package/src/app/agent-request-context.js +23 -0
  89. package/src/cli/command-registry.js +2 -0
  90. package/src/cli/employee-command.js +17 -0
  91. package/src/cli/remote-mcp-command.js +282 -0
  92. package/src/cli/setup-command.js +5 -0
  93. package/src/cli/update-command.js +6 -43
  94. package/src/cli.js +10 -0
  95. package/src/employees/catalog/jefa.json +21 -0
  96. package/src/employees/install-package.js +77 -0
  97. package/src/employees/management-policy.js +88 -0
  98. package/src/employees/manifest.js +53 -0
  99. package/src/employees/package-registry.js +107 -0
  100. package/src/employees/project-selection.js +50 -0
  101. package/src/employees/recruitment.js +199 -0
  102. package/src/employees/service.js +83 -0
  103. package/src/employees/task-entry.js +39 -0
  104. package/src/graphiti/agent-knowledge-workflows.js +135 -25
  105. package/src/graphiti/federated-application.js +78 -35
  106. package/src/graphiti/project-agent-control-plane.js +20 -3
  107. package/src/graphiti/project-agent-mapping.js +155 -5
  108. package/src/graphiti/project-agent-memory.js +115 -0
  109. package/src/graphiti/project-agent-task-entry.js +126 -0
  110. package/src/graphiti/project-agent-workflows.js +30 -14
  111. package/src/graphiti/provider-client.js +66 -4
  112. package/src/http/api-router.js +30 -5
  113. package/src/http/employee-api-router.js +64 -0
  114. package/src/http/error-mapping.js +4 -0
  115. package/src/http/graph-api-router.js +2 -0
  116. package/src/http/request-policy.js +6 -3
  117. package/src/http/static-handler.js +4 -3
  118. package/src/mcp/create-mcp-server.js +98 -37
  119. package/src/mcp/direct-call.js +38 -0
  120. package/src/mcp/instructions.js +8 -7
  121. package/src/mcp/provider-task-context-registry.js +74 -0
  122. package/src/mcp/remote-http-server.js +761 -0
  123. package/src/mcp/session-id.js +29 -3
  124. package/src/mcp/task-context-registry.js +88 -29
  125. package/src/mcp/tool-annotations.js +10 -2
  126. package/src/mcp/tool-result.js +8 -3
  127. package/src/mcp-server.js +20 -8
  128. package/src/native-runtime/runtime.js +58 -8
  129. package/src/semantic-version.js +42 -0
  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/src/system/package-version-checker.js +72 -0
  141. package/src/system/system-service.js +3 -0
  142. package/dist/web/assets/KnowledgeEditDialog-CZ5ziPrD.js +0 -1
  143. package/dist/web/assets/KnowledgePage-BRYC9S5Y.js +0 -1
  144. package/dist/web/assets/PersonalProjectsPage-DEh8XWby.js +0 -1
  145. package/dist/web/assets/ProjectAgentsPage-Crh7PrXa.css +0 -1
  146. package/dist/web/assets/ProjectAgentsPage-DPnaH-TR.js +0 -11
  147. package/dist/web/assets/SearchableSelect-D3HM8YF8.js +0 -1
  148. package/dist/web/assets/conversation-launcher-settings-DrJEotdf.js +0 -1
  149. package/dist/web/assets/index-BnH5mcZx.css +0 -1
  150. package/dist/web/assets/index-GqJf1xqg.js +0 -2
@@ -0,0 +1,423 @@
1
+ """Atomic task activity, execution audit and terminal evidence writes."""
2
+
3
+ from fastapi import HTTPException
4
+
5
+ from .personal_project_access import authorize_personal_project
6
+ from .project_agent_executor_models import (
7
+ ProjectAgentExecutorActualReport,
8
+ ProjectAgentExecutorOutcomeEvidenceCreate,
9
+ )
10
+ from .project_agent_models import ProjectAgentModelStrategy, ProjectAgentProfile
11
+ from .project_agent_task_models import ProjectAgentTaskActivityCreate, ProjectAgentTaskRecord
12
+ from .provider_values import now_utc, stable_uuid
13
+ from .store_project_agent_task_recruitment import RECRUITMENT_CLAIM_TTL
14
+ from .store_transactions import query_store_transaction
15
+
16
+
17
+ TERMINAL_TASK_STATUSES = {"completed", "failed", "cancelled"}
18
+
19
+
20
+ class StoreProjectAgentTaskActivity:
21
+ """Persist activity and its derived audit within one request-owned transaction."""
22
+
23
+ async def record_project_agent_task_activity(
24
+ self,
25
+ actor: dict,
26
+ request: ProjectAgentTaskActivityCreate,
27
+ ) -> ProjectAgentTaskRecord:
28
+ async with query_store_transaction(self) as scoped:
29
+ return await scoped._record_project_agent_task_activity(actor, request)
30
+
31
+ async def _record_project_agent_task_activity(
32
+ self,
33
+ actor: dict,
34
+ request: ProjectAgentTaskActivityCreate,
35
+ ) -> ProjectAgentTaskRecord:
36
+ self._require_personal()
37
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
38
+ await authorize_personal_project(
39
+ self,
40
+ actor,
41
+ space,
42
+ request.personal_project_id,
43
+ )
44
+ # Acquire the task's write lock before reading its revision, transition
45
+ # state or idempotency events. A transaction alone permits two concurrent
46
+ # readers to validate the same revision before either obtains a lock.
47
+ await self.runtime.driver.execute_query(
48
+ '''
49
+ MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})
50
+ -[:HAS_PROJECT_AGENT_TASK]->(task:FuliProjectAgentTask {
51
+ personal_space_id: $personal_space_id,
52
+ personal_project_id: $personal_project_id,
53
+ task_id: $task_id
54
+ })
55
+ SET task._activity_write_lock = true
56
+ REMOVE task._activity_write_lock
57
+ RETURN task.task_id AS task_id
58
+ ''',
59
+ personal_space_id=request.personal_space_id,
60
+ personal_project_id=request.personal_project_id,
61
+ task_id=request.task_id,
62
+ )
63
+ row = await self._find_task_row(request.personal_space_id, request.task_id)
64
+ if not row:
65
+ raise HTTPException(status_code=404, detail='project Agent task not found')
66
+ raw_task = dict(row['task'])
67
+ if raw_task['personal_project_id'] != request.personal_project_id:
68
+ raise HTTPException(status_code=404, detail='project Agent task not found')
69
+ event_id = stable_uuid(
70
+ self.settings.provider_id,
71
+ request.personal_space_id,
72
+ 'project-agent-task-event',
73
+ request.task_id,
74
+ request.idempotency_key,
75
+ )
76
+ payload_hash = self._payload_hash(request)
77
+ existing_event = next(
78
+ (
79
+ dict(value)
80
+ for value in (row.get('event_rows') or [])
81
+ if value
82
+ and (value.get('event_id') or value.get('id')) == event_id
83
+ ),
84
+ None,
85
+ )
86
+ if existing_event:
87
+ if existing_event.get('payload_hash') != payload_hash:
88
+ raise HTTPException(
89
+ status_code=409,
90
+ detail=(
91
+ 'task event idempotency key was used with different input'
92
+ ),
93
+ )
94
+ return self._task_from_row(row)
95
+ self._validate_task_transition(
96
+ raw_task.get('status'),
97
+ request.status,
98
+ has_actual_executor=bool(request.actual_executor_id),
99
+ )
100
+ expected_revision = getattr(request, 'expected_revision', None)
101
+ if expected_revision is None:
102
+ expected_revision = raw_task.get('revision') or 0
103
+ if request.agent_id:
104
+ participant = next(
105
+ (
106
+ item for item in self._participant_maps(row)
107
+ if item['agent_id'] == request.agent_id
108
+ ),
109
+ None,
110
+ )
111
+ if not participant:
112
+ raise HTTPException(
113
+ status_code=409,
114
+ detail='Agent is not assigned to this task',
115
+ )
116
+ if participant.get('profile_json'):
117
+ profile = ProjectAgentProfile.model_validate_json(
118
+ participant['profile_json']
119
+ )
120
+ for application in request.reported_client_applications:
121
+ self._require_agent_client_allowed(
122
+ {'agent_id': request.agent_id, 'profile': profile},
123
+ application,
124
+ )
125
+ now = now_utc()
126
+ terminal_at = now if request.status in TERMINAL_TASK_STATUSES else None
127
+ actual_executor_id = getattr(request, 'actual_executor_id', None)
128
+ matched_executor_rule_id = getattr(request, 'matched_executor_rule_id', None)
129
+ executor_selection_reason = getattr(request, 'executor_selection_reason', None)
130
+ executor_fallback_reason = getattr(request, 'executor_fallback_reason', None)
131
+ executor_blocked_reason = getattr(request, 'executor_blocked_reason', None)
132
+ worker_id = getattr(request, 'worker_id', None)
133
+ worker_label = getattr(request, 'worker_label', None)
134
+ worker_occupation_emoji = request.worker_occupation_emoji
135
+ worker_status = getattr(request, 'worker_status', None)
136
+ evidence_json = {
137
+ key: value.model_dump_json() if value is not None else None
138
+ for key, value in (
139
+ ('token_usage', request.token_usage),
140
+ ('worker_runtime', request.worker_runtime),
141
+ )
142
+ }
143
+ actual_recorder = None
144
+ actual_report = None
145
+ if actual_executor_id:
146
+ actual_recorder = getattr(
147
+ self,
148
+ 'record_project_agent_executor_actual',
149
+ None,
150
+ )
151
+ if not callable(actual_recorder):
152
+ raise HTTPException(
153
+ status_code=503,
154
+ detail='executor evidence control plane is unavailable',
155
+ )
156
+ strategy = (
157
+ ProjectAgentModelStrategy.model_validate_json(
158
+ raw_task['effective_model_strategy_json']
159
+ )
160
+ if raw_task.get('effective_model_strategy_json')
161
+ else ProjectAgentModelStrategy()
162
+ )
163
+ actual_report = ProjectAgentExecutorActualReport(
164
+ personal_space_id=request.personal_space_id,
165
+ personal_project_id=request.personal_project_id,
166
+ task_id=request.task_id,
167
+ run_id=event_id,
168
+ agent_id=request.agent_id,
169
+ executor_id=actual_executor_id,
170
+ provider=request.actual_model_provider,
171
+ model=request.actual_model,
172
+ model_strategy=strategy,
173
+ model_strategy_source=(
174
+ raw_task.get('model_strategy_source') or 'coordinator'
175
+ ),
176
+ matched_rule_id=matched_executor_rule_id,
177
+ fallback_reason=executor_fallback_reason,
178
+ idempotency_key=f'actual:{event_id}',
179
+ occurred_at=now,
180
+ source_application=request.source_application,
181
+ source_session_id=request.source_session_id,
182
+ )
183
+ records, _, _ = await self.runtime.driver.execute_query(
184
+ '''
185
+ MATCH (space:FuliSpace {
186
+ id: $personal_space_id, kind: 'personal'
187
+ })-[:HAS_PROJECT_AGENT_TASK]->
188
+ (task:FuliProjectAgentTask {
189
+ personal_space_id: $personal_space_id,
190
+ task_id: $task_id
191
+ })
192
+ WHERE coalesce(task.revision, 0) = $expected_revision
193
+ OR EXISTS {
194
+ MATCH (task)-[:HAS_TASK_EVENT]->
195
+ (:FuliProjectAgentTaskEvent {id: $event_id})
196
+ }
197
+ WITH space, task
198
+ WHERE task.status <> 'awaiting_recruitment'
199
+ OR $status = 'awaiting_recruitment'
200
+ OR task.recruitment_provisioning_claimed_at IS NULL
201
+ OR task.recruitment_provisioning_claimed_at <=
202
+ $recruitment_claim_expired_before
203
+ OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
204
+ (agent:FuliProjectAgent {agent_id: $agent_id})
205
+ WHERE $agent_id IS NULL OR EXISTS {
206
+ MATCH (task)-[:HAS_PARTICIPANT]->(agent)
207
+ }
208
+ WITH task, agent,
209
+ task.lead_agent_id IS NULL AND $status = 'running'
210
+ AS promote_lead
211
+ WHERE NOT promote_lead OR (
212
+ agent IS NOT NULL AND NOT EXISTS {
213
+ MATCH (task)-[existing_lead:HAS_PARTICIPANT]->()
214
+ WHERE existing_lead.role = 'lead'
215
+ }
216
+ )
217
+ MERGE (event:FuliProjectAgentTaskEvent {id: $event_id})
218
+ ON CREATE SET event.event_id = $event_id,
219
+ event.task_id = $task_id,
220
+ event.agent_id = $agent_id,
221
+ event.status = $status,
222
+ event.actor_kind = $actor_kind,
223
+ event.summary = $summary,
224
+ event.payload_hash = $payload_hash,
225
+ event.source_application = $source_application,
226
+ event.source_session_id = $source_session_id,
227
+ event.source_session_url = $source_session_url,
228
+ event.tools_used = $tools_used,
229
+ event.actual_model_provider = $actual_model_provider,
230
+ event.actual_model = $actual_model,
231
+ event.actual_executor_id = $actual_executor_id,
232
+ event.matched_executor_rule_id =
233
+ $matched_executor_rule_id,
234
+ event.executor_selection_reason =
235
+ $executor_selection_reason,
236
+ event.executor_fallback_reason =
237
+ $executor_fallback_reason,
238
+ event.executor_blocked_reason =
239
+ $executor_blocked_reason,
240
+ event.worker_id = $worker_id,
241
+ event.worker_label = $worker_label,
242
+ event.worker_occupation_emoji = $worker_occupation_emoji,
243
+ event.worker_status = $worker_status,
244
+ event.token_usage_json = $token_usage_json,
245
+ event.worker_runtime_json = $worker_runtime_json,
246
+ event.activity_date = CASE
247
+ WHEN $terminal_at IS NULL THEN null
248
+ ELSE date($terminal_at) END,
249
+ event.created_at = $updated_at
250
+ WITH task, event, agent, promote_lead,
251
+ event.payload_hash = $payload_hash AS same_payload,
252
+ coalesce(task.revision, 0) = $expected_revision
253
+ AS applied_transition
254
+ FOREACH (_ IN CASE
255
+ WHEN same_payload AND applied_transition
256
+ THEN [1] ELSE [] END |
257
+ MERGE (task)-[:HAS_TASK_EVENT]->(event)
258
+ )
259
+ FOREACH (_ IN CASE
260
+ WHEN same_payload AND applied_transition AND agent IS NOT NULL
261
+ THEN [1] ELSE [] END |
262
+ MERGE (event)-[:EVENT_AGENT]->(agent)
263
+ )
264
+ FOREACH (_ IN CASE
265
+ WHEN same_payload AND applied_transition
266
+ THEN [1] ELSE [] END |
267
+ SET task.status = $status,
268
+ task.result_summary = CASE
269
+ WHEN $status = 'completed' THEN $summary
270
+ ELSE task.result_summary END,
271
+ task.failure_reason = CASE
272
+ WHEN $status = 'failed' THEN $summary
273
+ ELSE task.failure_reason END,
274
+ task.matched_executor_rule_id = CASE
275
+ WHEN $actual_executor_id IS NULL AND task.actual_run_id IS NULL
276
+ THEN coalesce($matched_executor_rule_id, task.matched_executor_rule_id)
277
+ ELSE task.matched_executor_rule_id END,
278
+ task.executor_selection_reason = coalesce(
279
+ $executor_selection_reason,
280
+ task.executor_selection_reason),
281
+ task.executor_fallback_reason = CASE
282
+ WHEN $actual_executor_id IS NULL AND task.actual_run_id IS NULL
283
+ THEN coalesce($executor_fallback_reason, task.executor_fallback_reason)
284
+ ELSE task.executor_fallback_reason END,
285
+ task.executor_blocked_reason = coalesce(
286
+ $executor_blocked_reason,
287
+ task.executor_blocked_reason),
288
+ task.completed_at = $terminal_at,
289
+ task.updated_at = CASE
290
+ WHEN task.updated_at IS NULL OR task.updated_at < $updated_at
291
+ THEN $updated_at ELSE task.updated_at END,
292
+ task.revision = coalesce(task.revision, 0) + 1
293
+ )
294
+ FOREACH (_ IN CASE
295
+ WHEN same_payload AND applied_transition AND agent IS NOT NULL
296
+ THEN [1] ELSE [] END |
297
+ SET task.lead_agent_id = CASE
298
+ WHEN promote_lead
299
+ THEN agent.agent_id ELSE task.lead_agent_id END
300
+ )
301
+ WITH task, event, same_payload, applied_transition, promote_lead
302
+ OPTIONAL MATCH (task)-[participant:HAS_PARTICIPANT]->
303
+ (participant_agent:FuliProjectAgent)
304
+ FOREACH (_ IN CASE
305
+ WHEN same_payload
306
+ AND applied_transition
307
+ AND participant IS NOT NULL
308
+ AND NOT (coalesce(participant.status, '') IN
309
+ ['completed', 'failed', 'cancelled'])
310
+ AND (
311
+ $terminal_at IS NOT NULL
312
+ OR $agent_id IS NULL
313
+ OR participant_agent.agent_id = $agent_id
314
+ )
315
+ THEN [1] ELSE [] END |
316
+ SET participant.status = $status,
317
+ participant.role = CASE
318
+ WHEN promote_lead
319
+ AND participant_agent.agent_id = $agent_id
320
+ THEN 'lead' ELSE participant.role END,
321
+ participant.updated_at = $updated_at,
322
+ participant.ended_at = $terminal_at
323
+ )
324
+ RETURN DISTINCT task, event, same_payload, applied_transition
325
+ ''',
326
+ personal_space_id=request.personal_space_id,
327
+ task_id=request.task_id,
328
+ expected_revision=expected_revision,
329
+ event_id=event_id,
330
+ agent_id=request.agent_id,
331
+ status=request.status,
332
+ recruitment_claim_expired_before=now - RECRUITMENT_CLAIM_TTL,
333
+ actor_kind=request.actor_kind,
334
+ summary=request.summary,
335
+ payload_hash=payload_hash,
336
+ source_application=request.source_application,
337
+ source_session_id=request.source_session_id,
338
+ source_session_url=request.source_session_url,
339
+ tools_used=request.tools_used,
340
+ actual_model_provider=request.actual_model_provider,
341
+ actual_model=request.actual_model,
342
+ actual_executor_id=actual_executor_id,
343
+ matched_executor_rule_id=matched_executor_rule_id,
344
+ executor_selection_reason=executor_selection_reason,
345
+ executor_fallback_reason=executor_fallback_reason,
346
+ executor_blocked_reason=executor_blocked_reason,
347
+ worker_id=worker_id,
348
+ worker_label=worker_label,
349
+ worker_occupation_emoji=worker_occupation_emoji,
350
+ worker_status=worker_status,
351
+ token_usage_json=evidence_json.get('token_usage'),
352
+ worker_runtime_json=evidence_json.get('worker_runtime'),
353
+ terminal_at=terminal_at,
354
+ updated_at=now,
355
+ )
356
+ if not records:
357
+ raise HTTPException(
358
+ status_code=409,
359
+ detail='task revision is stale or participant is invalid',
360
+ )
361
+ if not records[0]['same_payload']:
362
+ raise HTTPException(
363
+ status_code=409,
364
+ detail='task event idempotency key was used with different input',
365
+ )
366
+ if not records[0].get('applied_transition'):
367
+ return await self.get_project_agent_task(
368
+ actor,
369
+ request.personal_space_id,
370
+ request.task_id,
371
+ )
372
+ if actual_report:
373
+ # This is the sole owner of actual projection fields: an older
374
+ # activity observation must not partially overwrite a newer run.
375
+ await actual_recorder(actor, actual_report)
376
+ evidence_status = (
377
+ request.status
378
+ if request.status in TERMINAL_TASK_STATUSES
379
+ else worker_status
380
+ )
381
+ if (
382
+ evidence_status in TERMINAL_TASK_STATUSES
383
+ and actual_executor_id
384
+ and request.agent_id
385
+ ):
386
+ evidence_recorder = getattr(
387
+ self,
388
+ 'record_project_agent_executor_outcome_evidence',
389
+ None,
390
+ )
391
+ if callable(evidence_recorder):
392
+ strategy = (
393
+ ProjectAgentModelStrategy.model_validate_json(
394
+ raw_task['effective_model_strategy_json']
395
+ )
396
+ if raw_task.get('effective_model_strategy_json')
397
+ else ProjectAgentModelStrategy()
398
+ )
399
+ await evidence_recorder(
400
+ actor,
401
+ ProjectAgentExecutorOutcomeEvidenceCreate(
402
+ personal_space_id=request.personal_space_id,
403
+ personal_project_id=request.personal_project_id,
404
+ work_kind=raw_task['work_kind'],
405
+ agent_id=request.agent_id,
406
+ executor_id=actual_executor_id,
407
+ task_id=request.task_id,
408
+ run_id=event_id,
409
+ model_strategy=strategy,
410
+ evidence_kind='terminal_outcome',
411
+ source='system_terminal',
412
+ terminal_outcome=evidence_status,
413
+ reference_ids=[event_id],
414
+ idempotency_key=f'terminal:{event_id}',
415
+ occurred_at=now,
416
+ ),
417
+ )
418
+ await self._archive_finished_temporary_agent(request, raw_task, now)
419
+ return await self.get_project_agent_task(
420
+ actor,
421
+ request.personal_space_id,
422
+ request.task_id,
423
+ )
@@ -0,0 +1,152 @@
1
+ """Recoverable participant and recruitment links for durable Agent tasks."""
2
+
3
+ import json
4
+ from fastapi import HTTPException
5
+
6
+
7
+ class StoreProjectAgentTaskPersistence:
8
+ """Persist idempotent task edges and repair them on task replay."""
9
+
10
+ async def _persist_task_links(
11
+ self,
12
+ request,
13
+ *,
14
+ transaction,
15
+ task_id,
16
+ participants,
17
+ recruitment_links,
18
+ status,
19
+ created_at,
20
+ updated_at=None,
21
+ ended_at=None,
22
+ ):
23
+ # Canonical lock order avoids mirrored multi-Agent submissions taking
24
+ # Agent node locks in opposite orders.
25
+ for participant in sorted(
26
+ participants,
27
+ key=lambda item: item['agent_id'],
28
+ ):
29
+ result = await transaction.run(
30
+ '''
31
+ MATCH (space:FuliSpace {
32
+ id: $personal_space_id, kind: 'personal'
33
+ })-[:HAS_PROJECT_AGENT_TASK]->
34
+ (task:FuliProjectAgentTask {task_id: $task_id})
35
+ MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->
36
+ (agent:FuliProjectAgent {agent_id: $agent_id})
37
+ // Share the Agent node lock with archive operations. If an
38
+ // archive wins, the active-status guard makes this link fail;
39
+ // if this write wins, archive observes the participant.
40
+ SET agent._task_lifecycle_lock = true
41
+ REMOVE agent._task_lifecycle_lock
42
+ WITH task, agent
43
+ WHERE agent.status = 'active'
44
+ OR $status IN ['completed', 'failed', 'cancelled']
45
+ MERGE (task)-[participant:HAS_PARTICIPANT]->(agent)
46
+ ON CREATE SET participant.role = $role,
47
+ participant.status = $status,
48
+ participant.assignment_summary =
49
+ $assignment_summary,
50
+ participant.joined_at = $created_at,
51
+ participant.updated_at = $updated_at,
52
+ participant.ended_at = $ended_at
53
+ RETURN count(participant) AS linked_count
54
+ ''',
55
+ personal_space_id=request.personal_space_id,
56
+ task_id=task_id,
57
+ agent_id=participant['agent_id'],
58
+ role=participant['role'],
59
+ status=status,
60
+ assignment_summary=participant.get('assignment_summary'),
61
+ created_at=created_at,
62
+ updated_at=updated_at or created_at,
63
+ ended_at=ended_at,
64
+ )
65
+ await self._require_task_link(result)
66
+ for linked in recruitment_links:
67
+ result = await transaction.run(
68
+ '''
69
+ MATCH (space:FuliSpace {
70
+ id: $personal_space_id, kind: 'personal'
71
+ })-[:HAS_PROJECT_AGENT_TASK]->
72
+ (task:FuliProjectAgentTask {task_id: $task_id})
73
+ MATCH (space)-[:HAS_PROJECT_AGENT_RECRUITMENT]->
74
+ (recruitment:FuliProjectAgentRecruitment {
75
+ recruitment_id: $recruitment_id
76
+ })
77
+ SET task.recruitment_id = CASE
78
+ WHEN $is_primary THEN $recruitment_id
79
+ ELSE task.recruitment_id END,
80
+ task.hr_agent_id = CASE
81
+ WHEN $is_primary THEN recruitment.hr_agent_id
82
+ ELSE task.hr_agent_id END
83
+ MERGE (task)-[:TRIGGERED_RECRUITMENT]->(recruitment)
84
+ RETURN count(recruitment) AS linked_count
85
+ ''',
86
+ personal_space_id=request.personal_space_id,
87
+ task_id=task_id,
88
+ recruitment_id=linked['recruitment_id'],
89
+ is_primary=bool(linked.get('is_primary')),
90
+ )
91
+ await self._require_task_link(result)
92
+
93
+ @staticmethod
94
+ async def _require_task_link(result):
95
+ records = [record async for record in result]
96
+ if len(records) != 1 or records[0]['linked_count'] != 1:
97
+ raise HTTPException(
98
+ status_code=409,
99
+ detail='task participant or recruitment target is unavailable',
100
+ )
101
+
102
+ async def _repair_task_persistence(self, request, raw_task):
103
+ participant_plan = self._task_link_plan(
104
+ raw_task.get('participant_plan_json')
105
+ )
106
+ recruitment_plan = self._task_link_plan(
107
+ raw_task.get('recruitment_plan_json')
108
+ )
109
+ if not participant_plan and not recruitment_plan:
110
+ return
111
+ async with self.runtime.driver.transaction() as transaction:
112
+ # Take the same task-node write lock used by activity transitions before
113
+ # reading lifecycle state. The initial replay lookup may already be stale.
114
+ result = await transaction.run(
115
+ '''
116
+ MATCH (space:FuliSpace {
117
+ id: $personal_space_id, kind: 'personal'
118
+ })-[:HAS_PROJECT_AGENT_TASK]->
119
+ (task:FuliProjectAgentTask {task_id: $task_id})
120
+ WHERE task.personal_project_id = $personal_project_id
121
+ SET task.revision = coalesce(task.revision, 0)
122
+ RETURN task
123
+ ''',
124
+ personal_space_id=request.personal_space_id,
125
+ personal_project_id=request.personal_project_id,
126
+ task_id=raw_task['task_id'],
127
+ )
128
+ records = [record async for record in result]
129
+ if len(records) != 1:
130
+ raise HTTPException(status_code=409, detail='task is unavailable for repair')
131
+ current = dict(records[0]['task'])
132
+ await self._persist_task_links(
133
+ request,
134
+ transaction=transaction,
135
+ task_id=raw_task['task_id'],
136
+ participants=participant_plan,
137
+ recruitment_links=recruitment_plan,
138
+ status=current['status'],
139
+ created_at=current['created_at'],
140
+ updated_at=current.get('updated_at'),
141
+ ended_at=current.get('completed_at'),
142
+ )
143
+
144
+ @staticmethod
145
+ def _task_link_plan(value):
146
+ if not value:
147
+ return []
148
+ try:
149
+ parsed = json.loads(value)
150
+ except (TypeError, ValueError):
151
+ return []
152
+ return parsed if isinstance(parsed, list) else []