@tea-agent/loop-agent 0.33.6-beta.0 → 0.33.6

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 (64) hide show
  1. package/CHANGELOG.md +29 -4
  2. package/dist/application/task-lifecycle/advance.js +254 -4
  3. package/dist/application/task-lifecycle/gates.js +50 -0
  4. package/dist/application/task-lifecycle/observe.js +11 -2
  5. package/dist/commands/init-upgrade.js +32 -1
  6. package/dist/commands/init.js +94 -3
  7. package/dist/executors/shell-write-guard.js +26 -8
  8. package/dist/shared/operator/capabilities.js +72 -42
  9. package/dist/shared/resilient-git.js +133 -0
  10. package/dist/task/source-prepare/artifact-meta.js +137 -0
  11. package/dist/task/source-prepare/index.js +2 -0
  12. package/dist/task/source-prepare/parse-intent.js +58 -10
  13. package/dist/task/source-prepare/prepare.js +180 -16
  14. package/dist/task/source-prepare/reference-integrity.js +18 -2
  15. package/dist/task/source-prepare/semantic-intake.js +404 -0
  16. package/dist/worker/console/app-data.js +2 -0
  17. package/dist/worker/console/chat/chat-event-store.js +190 -25
  18. package/dist/worker/console/chat/pi-console-config.js +250 -32
  19. package/dist/worker/console/chat/pi-runtime.js +625 -71
  20. package/dist/worker/console/chat/resource-loader.js +5 -4
  21. package/dist/worker/console/chat/routes.js +324 -146
  22. package/dist/worker/console/chat/runtime-context.js +48 -12
  23. package/dist/worker/console/chat/runtime-selection.js +59 -0
  24. package/dist/worker/console/chat/shortcuts.js +1 -0
  25. package/dist/worker/console/chat/tool-adapter.js +9 -3
  26. package/dist/worker/console/chat/tools.js +5 -1
  27. package/dist/worker/console/dag-execution-receipt.js +380 -0
  28. package/dist/worker/console/operator-actions.js +559 -68
  29. package/dist/worker/console/server.js +8 -15
  30. package/dist/worker/console/static/assets/index-BUOLppPr.js +28 -0
  31. package/dist/worker/console/static/assets/index-C1KzazY5.css +1 -0
  32. package/dist/worker/console/static/index.html +2 -2
  33. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
  34. package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
  35. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
  36. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +16 -0
  37. package/dist/worker/console/static-src/operator-chat/useChatStream.js +210 -184
  38. package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
  39. package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
  40. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
  41. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
  42. package/dist/worker/delivery/final-verification.js +13 -5
  43. package/dist/worker/delivery/package.js +31 -19
  44. package/dist/worker/delivery/verification-bundle.js +6 -4
  45. package/dist/worker/observe/static/operator-chrome.css +5 -2
  46. package/dist/worker/observe/static/operator-chrome.js +6 -1
  47. package/dist/worker/observe/static/styles.css +39 -9
  48. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +2 -27
  49. package/dist/workflows/dag/backend-test-module-stem.js +0 -5
  50. package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
  51. package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
  52. package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
  53. package/dist/workflows/dag/init-hybrid.js +12 -20
  54. package/dist/workflows/dag/types.js +0 -7
  55. package/dist/workflows/dag/workspace-checkpoint.js +8 -27
  56. package/docs/templates/backend-test-dag.json +10 -10
  57. package/harness.json +1 -1
  58. package/package.json +1 -1
  59. package/skills/loop-agent/references/command-reference.md +3 -1
  60. package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
  61. package/skills/loop-agent/references/task-workflow.md +4 -0
  62. package/dist/worker/console/chat/instruction-skills.js +0 -217
  63. package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
  64. package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
@@ -1,19 +1,44 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { formatModelRef, mergeSessionRuntime, runtimeSelectionFromRecord, sessionRuntimeMatches, } from "../../chat/runtime-selection.js";
2
+ import { formatModelRef, mergeSessionRuntime, sessionRuntimeMatches, } from "../../chat/runtime-selection.js";
3
3
  import { confirmationToken } from "./format.js";
4
+ import { mergeRuntimeContextWithSnapshot, runtimeSelectionFromSnapshot, } from "./runtime-snapshot-store.js";
4
5
  import { buildToolCatalog } from "./tools-catalog.js";
6
+ import { adoptMutationSnapshot, dispatchRuntimeSnapshot, getRuntimeSnapshotState, refreshRuntimeSnapshot, settleMutationLifecycle, useRuntimeSnapshot, } from "./useRuntimeSnapshot.js";
7
+ /**
8
+ * Monotonic mutation sequence shared across switchRuntime calls (RF-04). Each
9
+ * mutation captures its own token; only the LATEST started token may settle
10
+ * mutationBusy/switching, so a slow earlier response can never clear busy
11
+ * while a newer mutation is still in flight.
12
+ */
13
+ let mutationSeq = 0;
5
14
  /** Capabilities catalog, model / thinking-level switcher, runtime-context
6
15
  * popover and the outside-click dismissal for both transient menus. */
7
16
  export function useRuntimeControls(params) {
8
17
  const { origin, session, setSession, refs, setError } = params;
9
18
  const [capabilities, setCapabilities] = useState(null);
10
19
  const [models, setModels] = useState([]);
20
+ const [modelsStatus, setModelsStatus] = useState("idle");
21
+ const [modelsError, setModelsError] = useState(null);
11
22
  const [thinkingLevels, setThinkingLevels] = useState([]);
12
23
  const [runtimeContext, setRuntimeContext] = useState(null);
24
+ const [runtimeContextError, setRuntimeContextError] = useState(null);
25
+ const [switching, setSwitching] = useState(false);
13
26
  const [toolsOpen, setToolsOpen] = useState(false);
14
27
  const [runtimeContextOpen, setRuntimeContextOpen] = useState(false);
15
28
  const toolsMenuRef = useRef(null);
16
29
  const runtimeContextMenuRef = useRef(null);
30
+ const switchTokenRef = useRef(0);
31
+ // RF3-02: a session switch immediately abandons the in-flight mutation
32
+ // lifecycle — the local token is invalidated and switching cleared, so a
33
+ // late response from the previous session can never settle the new
34
+ // session's busy/switching. (The store side is reset synchronously by
35
+ // attachRuntimeSnapshotController's mutation-reset.)
36
+ useEffect(() => {
37
+ switchTokenRef.current = -1;
38
+ setSwitching(false);
39
+ }, [session?.sessionId, setSwitching]);
40
+ const snapshotState = useRuntimeSnapshot();
41
+ const snapshot = snapshotState.snapshot;
17
42
  const loadCapabilities = useCallback(async () => {
18
43
  try {
19
44
  const res = await fetch(`${origin}/api/operator/v1/chat/capabilities`);
@@ -29,106 +54,202 @@ export function useRuntimeControls(params) {
29
54
  useEffect(() => {
30
55
  void loadCapabilities();
31
56
  }, [loadCapabilities]);
32
- useEffect(() => {
57
+ // Models + thinking levels: AC-01/AC-06 — the GET may reopen a historical
58
+ // session server-side; a failure is a STRUCTURED error state (with retry),
59
+ // never silently folded into an empty list.
60
+ const loadModels = useCallback(async () => {
33
61
  if (!session) {
34
62
  setModels([]);
35
- setRuntimeContext(null);
63
+ setModelsStatus("idle");
36
64
  return;
37
65
  }
38
66
  const sessionId = session.sessionId;
39
- const base = `${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}`;
40
- void fetch(`${base}/models`, { credentials: "include" })
41
- .then((r) => (r.ok ? r.json() : null))
42
- .then((body) => {
67
+ setModelsStatus("loading");
68
+ setModelsError(null);
69
+ try {
70
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/models`, { credentials: "include" });
71
+ const body = (await res.json().catch(() => null));
43
72
  // Guard against a stale response landing after the user switched/
44
73
  // created/forked a session: only adopt models for the session we
45
74
  // actually requested them for.
46
- if (!body || refs.sessionRef.current?.sessionId !== sessionId)
75
+ if (refs.sessionRef.current?.sessionId !== sessionId)
76
+ return;
77
+ if (!res.ok || !body) {
78
+ setModelsStatus("error");
79
+ setModelsError(body
80
+ ?.error?.message ?? `模型列表读取失败(HTTP ${res.status})`);
47
81
  return;
82
+ }
48
83
  setModels(body.models ?? []);
49
84
  setThinkingLevels(body.thinkingLevels ?? []);
50
- });
85
+ setModelsStatus("ready");
86
+ }
87
+ catch (reason) {
88
+ if (refs.sessionRef.current?.sessionId !== sessionId)
89
+ return;
90
+ setModelsStatus("error");
91
+ setModelsError(reason instanceof Error ? reason.message : String(reason));
92
+ }
93
+ }, [origin, session, refs]);
94
+ useEffect(() => {
95
+ void loadModels();
96
+ }, [loadModels]);
97
+ useEffect(() => {
98
+ if (!session) {
99
+ setRuntimeContext(null);
100
+ setRuntimeContextError(null);
101
+ return;
102
+ }
103
+ const sessionId = session.sessionId;
104
+ const base = `${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}`;
105
+ setRuntimeContextError(null);
51
106
  void fetch(`${base}/runtime-context`, { credentials: "include" })
52
- .then((r) => (r.ok ? r.json() : null))
53
- .then((body) => {
107
+ .then(async (r) => {
108
+ const body = await r.json().catch(() => null);
54
109
  // Stale-response guard: a slow runtime-context reply for session A
55
110
  // must not overwrite the context/session state of the now-current
56
111
  // session B.
57
112
  if (!body?.context || refs.sessionRef.current?.sessionId !== sessionId)
58
113
  return;
59
- setRuntimeContext(body.context);
114
+ // RF5: the response body NEVER writes model/thinkingLevel — it
115
+ // carries no revision semantics, so a late reply for the same
116
+ // session could hold a stale selection. Selection is derived only
117
+ // from the LATEST adopted snapshot, read HERE at .then time (right
118
+ // before the setRuntimeContext functional updater runs) via the
119
+ // production read-only accessor getRuntimeSnapshotState() — the
120
+ // only immediate authority when the store has already adopted a
121
+ // new revision but this component has not rerendered yet. A
122
+ // render-synchronized value (snapshotRef) could still be the old
123
+ // revision in that window, so it is removed entirely.
124
+ // systemPrompt/resources/suffix/readOnly still come from the
125
+ // response. Zero session writes and zero store writes: the
126
+ // snapshot hydration effect never re-runs and the menu/popover
127
+ // selection never jumps back.
60
128
  const ctx = body.context;
61
- const patch = {
62
- model: ctx.model,
63
- thinkingLevel: ctx.thinkingLevel,
64
- };
65
- setSession((current) => {
66
- if (!current || current.sessionId !== sessionId)
67
- return current;
68
- if (sessionRuntimeMatches(current, patch))
69
- return current;
70
- const merged = mergeSessionRuntime(current, patch);
71
- refs.sessionRef.current = merged;
72
- return merged;
73
- });
74
- });
75
- }, [origin, session?.sessionId, refs, setSession]);
76
- const switchRuntime = useCallback(async (body) => {
77
- if (!session)
78
- return;
79
- // Capture the target session so a slow model/thinking response cannot
80
- // write its result into a different (newly created/forked/switched)
81
- // session the user has since moved to.
82
- const targetSessionId = session.sessionId;
83
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(session.sessionId)}/model`, {
84
- method: "POST",
85
- credentials: "include",
86
- headers: {
87
- "content-type": "application/json",
88
- "x-loop-console-confirmation": confirmationToken(),
89
- },
90
- body: JSON.stringify(body),
129
+ const adoptedSnapshot = getRuntimeSnapshotState().snapshot;
130
+ setRuntimeContext((current) => mergeRuntimeContextWithSnapshot(current, ctx, adoptedSnapshot, sessionId));
131
+ })
132
+ .catch(() => {
133
+ if (refs.sessionRef.current?.sessionId !== sessionId)
134
+ return;
135
+ setRuntimeContextError("注入上下文读取失败,请重试");
91
136
  });
92
- if (!res.ok) {
93
- // Only surface the failure for the session that still owns it.
94
- if (refs.sessionRef.current?.sessionId === targetSessionId)
95
- setError("模型或思考等级切换失败");
96
- return;
97
- }
98
- const payload = (await res.json().catch(() => ({})));
99
- if (!payload.session)
100
- return;
101
- // Drop the result entirely if the active session changed mid-flight.
102
- if (refs.sessionRef.current?.sessionId !== targetSessionId)
137
+ }, [origin, session?.sessionId, refs]);
138
+ // AC-01/RF3-01: hydrate the Composer from the unified snapshot real
139
+ // model and thinking selection; `auto` displays the Pi actual effective
140
+ // level. Derived session/runtimeContext state ONLY comes from a snapshot
141
+ // the reducer guards accepted: the mutation success branch never writes
142
+ // session/runtimeContext directly, so this effect re-runs on adoption and
143
+ // is a no-op for rejected/missing snapshots (zero derived writes).
144
+ useEffect(() => {
145
+ if (!snapshot || !session || snapshot.sessionId !== session.sessionId)
103
146
  return;
147
+ const patch = runtimeSelectionFromSnapshot(snapshot);
104
148
  setSession((current) => {
105
- if (!current || current.sessionId !== targetSessionId)
149
+ if (!current || current.sessionId !== snapshot.sessionId)
106
150
  return current;
107
- const patch = runtimeSelectionFromRecord(payload.session);
108
151
  if (sessionRuntimeMatches(current, patch))
109
152
  return current;
110
153
  const merged = mergeSessionRuntime(current, patch);
111
154
  refs.sessionRef.current = merged;
112
155
  return merged;
113
156
  });
114
- setRuntimeContext((current) => current
115
- ? {
157
+ setRuntimeContext((current) => {
158
+ if (!current)
159
+ return current;
160
+ const next = {
116
161
  ...current,
117
- ...(payload.session.modelProvider && payload.session.modelId
118
- ? {
119
- model: {
120
- provider: payload.session.modelProvider,
121
- modelId: payload.session.modelId,
122
- },
123
- }
162
+ ...(patch.model ? { model: patch.model } : {}),
163
+ ...(patch.thinkingLevel
164
+ ? { thinkingLevel: patch.thinkingLevel }
124
165
  : {}),
125
- ...(payload.session.thinkingLevel
126
- ? { thinkingLevel: payload.session.thinkingLevel }
127
- : body.thinkingLevel
128
- ? { thinkingLevel: body.thinkingLevel }
129
- : {}),
166
+ };
167
+ if (next.model?.provider === current.model?.provider &&
168
+ next.model?.modelId === current.model?.modelId &&
169
+ next.thinkingLevel === current.thinkingLevel) {
170
+ return current;
171
+ }
172
+ return next;
173
+ });
174
+ }, [snapshot, session?.sessionId, refs, setSession, setRuntimeContext]);
175
+ const switchRuntime = useCallback(async (body) => {
176
+ if (!session)
177
+ return false;
178
+ // Capture the target session so a slow model/thinking response cannot
179
+ // write its result into a different (newly created/forked/switched)
180
+ // session the user has since moved to.
181
+ const targetSessionId = session.sessionId;
182
+ // RF-04: token the mutation start; only the latest token's settle
183
+ // clears mutationBusy/switching (stale earlier settles can't win).
184
+ const token = ++mutationSeq;
185
+ switchTokenRef.current = token;
186
+ dispatchRuntimeSnapshot({ type: "mutation-start", token });
187
+ setSwitching(true);
188
+ try {
189
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(session.sessionId)}/model`, {
190
+ method: "POST",
191
+ credentials: "include",
192
+ headers: {
193
+ "content-type": "application/json",
194
+ "x-loop-console-confirmation": confirmationToken(),
195
+ },
196
+ body: JSON.stringify(body),
197
+ });
198
+ const payload = (await res.json().catch(() => ({})));
199
+ // RF2-02: an out-of-order response (its token is no longer the
200
+ // latest started mutation) is dropped ENTIRELY on BOTH the success
201
+ // and the failure branch: no setSession/setRuntimeContext, no
202
+ // mutation-adopt, no busy settle, no refresh — the newest mutation
203
+ // owns the UI and a slow earlier reply can never overwrite it.
204
+ if (switchTokenRef.current !== token)
205
+ return false;
206
+ if (!res.ok) {
207
+ // RF2-01: a non-2xx response (e.g. 503 PI_RUNTIME_UNAVAILABLE —
208
+ // the mutation already applied server-side but the snapshot
209
+ // projection failed) for the still-current session shows the
210
+ // structured error, then triggers a REAL GET
211
+ // /runtime-snapshot so the store converges on the server
212
+ // selection via the load-success revision guard. When the
213
+ // session switched or the response is stale: no error, no
214
+ // refresh, no state writes.
215
+ if (refs.sessionRef.current?.sessionId === targetSessionId) {
216
+ setError(payload.error?.message ??
217
+ `模型或思考等级切换失败(HTTP ${res.status})`);
218
+ void refreshRuntimeSnapshot();
219
+ }
220
+ return false;
130
221
  }
131
- : current);
222
+ if (!payload.session)
223
+ return false;
224
+ // Drop the result entirely if the active session changed mid-flight.
225
+ if (refs.sessionRef.current?.sessionId !== targetSessionId)
226
+ return false;
227
+ // RF3-01: derived session/runtimeContext state is ONLY written from
228
+ // a snapshot the reducer's three guards (latest mutationToken,
229
+ // current sessionId, monotonic revision) accepted — no direct
230
+ // setSession/setRuntimeContext here. A missing snapshot returns
231
+ // false with zero derived writes; on adoption the hydrate effect
232
+ // derives session + runtimeContext from the adopted snapshot.
233
+ return adoptMutationSnapshot(token, targetSessionId, payload.snapshot);
234
+ }
235
+ catch (reason) {
236
+ // RF2-02: stale out-of-order network failures are dropped too —
237
+ // no error, no refresh, no state writes for an expired token.
238
+ if (switchTokenRef.current !== token)
239
+ return false;
240
+ if (refs.sessionRef.current?.sessionId === targetSessionId)
241
+ setError(reason instanceof Error ? reason.message : String(reason));
242
+ return false;
243
+ }
244
+ finally {
245
+ // RF3-02: settle only when the target session is still active and
246
+ // the token is still the latest — a late old-session response
247
+ // neither dispatches an effective settle nor clears the current
248
+ // busy/switching.
249
+ const settled = settleMutationLifecycle(token, targetSessionId, refs);
250
+ if (settled && switchTokenRef.current === token)
251
+ setSwitching(false);
252
+ }
132
253
  }, [origin, session, refs, setSession, setError]);
133
254
  // Close tools catalog on outside click / Escape.
134
255
  useEffect(() => {
@@ -179,18 +300,46 @@ export function useRuntimeControls(params) {
179
300
  const toolCatalog = useMemo(() => buildToolCatalog(capabilities), [
180
301
  capabilities,
181
302
  ]);
303
+ // Snapshot is the single source of truth when ready; the /models fetch
304
+ // result is the fallback (both hit the same backend, never fake-empty).
305
+ const effectiveModels = snapshot
306
+ ? (snapshot.models ?? []).map((model) => ({
307
+ provider: model.provider,
308
+ id: model.id,
309
+ ...(model.name ? { name: model.name } : {}),
310
+ ...(model.hasCredentials !== undefined
311
+ ? { hasCredentials: model.hasCredentials }
312
+ : {}),
313
+ }))
314
+ : models;
315
+ const effectiveThinkingLevels = snapshot?.thinkingLevels?.length
316
+ ? snapshot.thinkingLevels.map((option) => option.value)
317
+ : thinkingLevels;
182
318
  const activeModel = session?.model ?? runtimeContext?.model;
183
319
  const activeModelRef = activeModel
184
320
  ? formatModelRef(activeModel.provider, activeModel.modelId)
185
321
  : "";
186
- const activeThinkingLevel = session?.thinkingLevel ?? runtimeContext?.thinkingLevel ?? "";
322
+ // AC-01/AC-02: thinking selection shows "auto" when no explicit override,
323
+ // with the Pi effective level available separately for the badge.
324
+ const activeThinkingLevel = snapshot?.selection?.thinkingSelection ?? session?.thinkingLevel ?? "";
325
+ const effectiveThinkingLevel = snapshot?.selection?.effectiveThinkingLevel ??
326
+ session?.thinkingLevel ??
327
+ undefined;
187
328
  const activeModelListed = !activeModelRef ||
188
- models.some((model) => formatModelRef(model.provider, model.id) === activeModelRef);
329
+ effectiveModels.some((model) => formatModelRef(model.provider, model.id) === activeModelRef);
189
330
  return {
190
331
  capabilities,
191
- models,
192
- thinkingLevels,
332
+ models: effectiveModels,
333
+ modelsStatus,
334
+ modelsError,
335
+ onRetryModels: () => void loadModels(),
336
+ thinkingLevels: effectiveThinkingLevels,
193
337
  runtimeContext,
338
+ runtimeContextError,
339
+ onRetryRuntimeContext: () => {
340
+ setRuntimeContextError(null);
341
+ void refreshRuntimeSnapshot();
342
+ },
194
343
  toolsOpen,
195
344
  setToolsOpen,
196
345
  runtimeContextOpen,
@@ -198,10 +347,12 @@ export function useRuntimeControls(params) {
198
347
  toolsMenuRef,
199
348
  runtimeContextMenuRef,
200
349
  switchRuntime,
350
+ switching,
201
351
  toolCatalog,
202
352
  activeModel,
203
353
  activeModelRef,
204
354
  activeThinkingLevel,
355
+ effectiveThinkingLevel,
205
356
  activeModelListed,
206
357
  };
207
358
  }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Operator Chat — runtime snapshot React binding (design §2.2 / AC-05/06).
3
+ *
4
+ * All seams that need the snapshot (ChatComposer /reload paths, resource
5
+ * panels, runtime-context popover) live in writable files that do NOT share a
6
+ * common React tree root (composer vs sidebar). This module therefore owns a
7
+ * single module-level store plus a controller attached by `useComposer` (which
8
+ * already holds origin + sessionId + refs). Components read the shared state
9
+ * with `useRuntimeSnapshot()` and trigger reloads with
10
+ * `reloadRuntimeSnapshot()` — every submit path shares the SAME state machine.
11
+ */
12
+ import { useSyncExternalStore } from "react";
13
+ import { confirmationToken } from "./format.js";
14
+ import { createRuntimeSnapshotStore, parseRuntimeSnapshotError, } from "./runtime-snapshot-store.js";
15
+ const store = createRuntimeSnapshotStore();
16
+ let controller;
17
+ function snapshotUrl(sessionId) {
18
+ return `/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}`;
19
+ }
20
+ /**
21
+ * Bind the current session to the shared store. Called from `useComposer` on
22
+ * every session change; switching sessions reloads the snapshot, clearing the
23
+ * session detaches cleanly. Stale replies are dropped by the sessionId guard.
24
+ */
25
+ export function attachRuntimeSnapshotController(input) {
26
+ const previous = controller;
27
+ controller = input;
28
+ if (previous && previous.sessionId === input.sessionId && previous.origin === input.origin) {
29
+ return;
30
+ }
31
+ // RF3-02: a session switch immediately resets the mutation lifecycle so the
32
+ // new session never inherits the old session's mutationBusy/token. Stale
33
+ // adopt/settle actions for the old session become no-ops via the token +
34
+ // sessionId guards in the reducer.
35
+ if (!previous || previous.sessionId !== input.sessionId) {
36
+ store.dispatch({ type: "mutation-reset" });
37
+ }
38
+ if (!input.sessionId) {
39
+ store.dispatch({ type: "reset" });
40
+ return;
41
+ }
42
+ store.dispatch({ type: "load-start", sessionId: input.sessionId });
43
+ void fetchSnapshot(input.sessionId, input.origin);
44
+ }
45
+ async function fetchSnapshot(sessionId, origin) {
46
+ try {
47
+ const res = await fetch(`${origin}${snapshotUrl(sessionId)}/runtime-snapshot`, { credentials: "include" });
48
+ const body = (await res.json().catch(() => ({})));
49
+ if (controller?.sessionId !== sessionId)
50
+ return; // stale session reply
51
+ if (!res.ok) {
52
+ store.dispatch({
53
+ type: "load-failure",
54
+ sessionId,
55
+ error: { ...parseRuntimeSnapshotError(body), status: res.status },
56
+ });
57
+ return;
58
+ }
59
+ if (!body.snapshot) {
60
+ store.dispatch({
61
+ type: "load-failure",
62
+ sessionId,
63
+ error: { status: res.status, message: "runtime snapshot missing in response" },
64
+ });
65
+ return;
66
+ }
67
+ store.dispatch({ type: "load-success", sessionId, snapshot: body.snapshot });
68
+ }
69
+ catch (error) {
70
+ if (controller?.sessionId !== sessionId)
71
+ return;
72
+ store.dispatch({
73
+ type: "load-failure",
74
+ sessionId,
75
+ error: { message: error instanceof Error ? error.message : String(error) },
76
+ });
77
+ }
78
+ }
79
+ /**
80
+ * POST /sessions/:id/reload (AC-05). Single-flight: a reload already in
81
+ * progress is NOT queued (AC-06). Returns true only on success.
82
+ */
83
+ export async function reloadRuntimeSnapshot() {
84
+ const ctrl = controller;
85
+ if (!ctrl?.sessionId)
86
+ return false;
87
+ if (store.getState().reloading)
88
+ return false; // busy reload: no silent queue
89
+ const sessionId = ctrl.sessionId;
90
+ store.dispatch({ type: "reload-start" });
91
+ try {
92
+ const res = await fetch(`${ctrl.origin}${snapshotUrl(sessionId)}/reload`, {
93
+ method: "POST",
94
+ credentials: "include",
95
+ headers: {
96
+ "content-type": "application/json",
97
+ "x-loop-console-confirmation": confirmationToken(),
98
+ },
99
+ body: "{}",
100
+ });
101
+ const body = (await res.json().catch(() => ({})));
102
+ if (controller?.sessionId !== sessionId)
103
+ return false;
104
+ if (!res.ok) {
105
+ store.dispatch({
106
+ type: "reload-failure",
107
+ error: { ...parseRuntimeSnapshotError(body), status: res.status },
108
+ });
109
+ return false;
110
+ }
111
+ if (!body.snapshot) {
112
+ store.dispatch({
113
+ type: "reload-failure",
114
+ error: { status: res.status, message: "reload succeeded but snapshot missing" },
115
+ });
116
+ return false;
117
+ }
118
+ store.dispatch({ type: "reload-success", snapshot: body.snapshot });
119
+ return true;
120
+ }
121
+ catch (error) {
122
+ if (controller?.sessionId !== sessionId)
123
+ return false;
124
+ store.dispatch({
125
+ type: "reload-failure",
126
+ error: { message: error instanceof Error ? error.message : String(error) },
127
+ });
128
+ return false;
129
+ }
130
+ }
131
+ /**
132
+ * Plain snapshot re-read (no Pi reload). Used after model/thinking mutations
133
+ * so the store atomically adopts the fresh revision.
134
+ */
135
+ export async function refreshRuntimeSnapshot() {
136
+ const ctrl = controller;
137
+ if (!ctrl?.sessionId)
138
+ return false;
139
+ const sessionId = ctrl.sessionId;
140
+ store.dispatch({ type: "load-start", sessionId });
141
+ void fetchSnapshot(sessionId, ctrl.origin);
142
+ return true;
143
+ }
144
+ /**
145
+ * RF3-01: production success-branch seam — `useRuntimeControls.switchRuntime`
146
+ * calls this INSTEAD of writing session/runtimeContext directly. Returns true
147
+ * only when the reducer's three guards (latest mutationToken, current
148
+ * sessionId, monotonic revision) actually adopted the snapshot; a missing
149
+ * snapshot is rejected up-front with zero derived writes. Adoption never
150
+ * settles mutationBusy — `settleMutationLifecycle` owns that.
151
+ */
152
+ export function adoptMutationSnapshot(token, sessionId, snapshot) {
153
+ if (!snapshot)
154
+ return false;
155
+ store.dispatch({ type: "mutation-adopt", token, sessionId, snapshot });
156
+ // The reducer adopts by atomic replacement: the current snapshot IS the
157
+ // proposed snapshot only when all three guards passed.
158
+ return store.getState().snapshot === snapshot;
159
+ }
160
+ /**
161
+ * RF3-02: production finally-branch seam — settles the mutation lifecycle only
162
+ * when the target session is STILL the active session AND the token is still
163
+ * the latest. A late response from a previous session (or a superseded token)
164
+ * returns false without dispatching, so it can neither clear the current
165
+ * busy/switching nor settle a lifecycle the session switch already reset.
166
+ */
167
+ export function settleMutationLifecycle(token, targetSessionId, refs) {
168
+ if (refs.sessionRef.current?.sessionId !== targetSessionId)
169
+ return false;
170
+ if (store.getState().mutationToken !== token)
171
+ return false;
172
+ store.dispatch({ type: "mutation-settle", token });
173
+ return true;
174
+ }
175
+ /** Subscribe the current component to the shared snapshot state. */
176
+ export function useRuntimeSnapshot() {
177
+ return useSyncExternalStore(store.subscribe, store.getState);
178
+ }
179
+ /**
180
+ * Dispatch an action into the shared snapshot store. Controller-side seams
181
+ * (e.g. the mutation token lifecycle in useRuntimeControls) drive the same
182
+ * state machine the components read, keeping every consumer consistent.
183
+ */
184
+ export function dispatchRuntimeSnapshot(action) {
185
+ store.dispatch(action);
186
+ }
187
+ /**
188
+ * RF5: production read-only accessor for the shared store — the only
189
+ * immediate authority for the currently adopted snapshot inside asynchronous
190
+ * callbacks (e.g. the /runtime-context response .then handler), even when the
191
+ * subscribing component has not rerendered yet. Read-only: it never bypasses
192
+ * the reducer, never writes state, and never notifies subscribers.
193
+ */
194
+ export function getRuntimeSnapshotState() {
195
+ return store.getState();
196
+ }
@@ -1,8 +1,7 @@
1
- import { execFile } from "node:child_process";
2
1
  import { createHash, randomUUID } from "node:crypto";
3
2
  import { mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
4
3
  import path from "node:path";
5
- import { promisify } from "node:util";
4
+ import { runResilientGitCommand } from "../../shared/resilient-git.js";
6
5
  import YAML from "yaml";
7
6
  import { controllerIdentitiesMatch, controllerIdentityExpectationFailure, resolveControllerIdentity, } from "../loop-agent/loop-agent-client.js";
8
7
  import { acceptanceSpecSchema } from "../task-graph/acceptance-schema.js";
@@ -15,7 +14,6 @@ import { taskSpecSchema } from "../task-spec/schema.js";
15
14
  import { preflightTargetRepo } from "../preflight.js";
16
15
  import { gitTransactionRecordSchema, transactionRecordPath } from "./git-transaction.js";
17
16
  import { buildFeatureVerificationBundle, writeFeatureVerificationBundle, } from "./verification-bundle.js";
18
- const execFileAsync = promisify(execFile);
19
17
  /**
20
18
  * Canonical Delivery evidence locations written by `feature verify-final`
21
19
  * (ADR 0007). Paths are repo-relative with forward slashes.
@@ -289,6 +287,16 @@ export async function writeEvidencePairAtomic(evidenceDir, value, fs = { rm }) {
289
287
  if (backedUp)
290
288
  await fs.rm(backup, { recursive: true, force: true }).catch(() => { });
291
289
  }
292
- async function git(repoRoot, args) { return (await execFileAsync("git", args, { cwd: repoRoot, encoding: "utf8" })).stdout.trim(); }
293
- async function gitRaw(repoRoot, args) { return (await execFileAsync("git", args, { cwd: repoRoot, encoding: "utf8" })).stdout; }
290
+ async function git(repoRoot, args) {
291
+ const result = await runResilientGitCommand({ cwd: repoRoot, args, readOnly: true });
292
+ if (result.code !== 0)
293
+ throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}`);
294
+ return result.stdout.trim();
295
+ }
296
+ async function gitRaw(repoRoot, args) {
297
+ const result = await runResilientGitCommand({ cwd: repoRoot, args, readOnly: true });
298
+ if (result.code !== 0)
299
+ throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}`);
300
+ return result.stdout;
301
+ }
294
302
  function isNotFound(error) { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); }