@yeaft/webchat-agent 1.0.214 → 1.0.215

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.
@@ -1 +1 @@
1
- {"version":"1.0.214"}
1
+ {"version":"1.0.215"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.214",
3
+ "version": "1.0.215",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,25 @@
1
+ function generation(value) {
2
+ return Math.max(1, Number(value) || 1);
3
+ }
4
+
5
+ export function eventMatchesActionGeneration(event, action) {
6
+ if (!event || !action) return false;
7
+ return generation(event.actionGeneration) === generation(action.generation);
8
+ }
9
+
10
+ export function runMatchesActionIdentity(run, action) {
11
+ if (!run || !action) return false;
12
+ const actionGeneration = generation(action.generation);
13
+ const manifest = run.executionManifest;
14
+ const runGeneration = generation(run.actionGeneration ?? manifest?.actionGeneration);
15
+ if (runGeneration !== actionGeneration) return false;
16
+
17
+ const actionSpecHash = typeof action.specHash === 'string' ? action.specHash : '';
18
+ const runSpecHash = typeof run.actionSpecHash === 'string' && run.actionSpecHash
19
+ ? run.actionSpecHash
20
+ : typeof manifest?.actionSpecHash === 'string' ? manifest.actionSpecHash : '';
21
+ if (!actionSpecHash || !runSpecHash) {
22
+ return actionGeneration === 1 && !actionSpecHash && !runSpecHash;
23
+ }
24
+ return runSpecHash === actionSpecHash;
25
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { eventMatchesActionGeneration, runMatchesActionIdentity } from './action-identity.js';
2
3
  import { normalizeSessionContextSnapshot } from './session-context.js';
3
4
 
4
5
  export const MAINLINE_CONTEXT_HARD_LIMIT_BYTES = 64 * 1024;
@@ -45,17 +46,10 @@ function stableRunOrder(left, right) {
45
46
  || String(right.id).localeCompare(String(left.id));
46
47
  }
47
48
 
48
- function runMatchesActionSpec(run, action) {
49
- const manifest = run?.executionManifest;
50
- return manifest?.schemaVersion === 2
51
- && manifest.actionGeneration === Math.max(1, count(action.generation) || 1)
52
- && manifest.actionSpecHash === (action.specHash || '');
53
- }
54
-
55
49
  function canonicalRun(action, runs) {
56
50
  const candidates = runs.filter(run => run?.actionId === action.id
57
51
  && TERMINAL_RUN_STATUSES.has(run.status)
58
- && runMatchesActionSpec(run, action));
52
+ && runMatchesActionIdentity(run, action));
59
53
  if (action.resultRunId) {
60
54
  return candidates.find(run => run.id === action.resultRunId) || null;
61
55
  }
@@ -83,9 +77,10 @@ function workItemMessageView(messages) {
83
77
  }));
84
78
  }
85
79
 
86
- function guidanceView(events, actionId) {
80
+ function guidanceView(events, action) {
87
81
  return (Array.isArray(events) ? events : [])
88
- .filter(event => event?.actionId === actionId
82
+ .filter(event => event?.actionId === action?.id
83
+ && eventMatchesActionGeneration(event, action)
89
84
  && ['action.guidance_added', 'action.input_added'].includes(event.type))
90
85
  .slice()
91
86
  .sort((left, right) => count(left.id) - count(right.id))
@@ -265,7 +260,7 @@ export function buildMainlineContextSnapshot(detail, action, budgetInput = {}) {
265
260
  };
266
261
  const sessionContext = normalizeSessionContextSnapshot(detail.sessionContext);
267
262
  const workItemMessages = workItemMessageView(detail.messages);
268
- const guidance = guidanceView(detail.events, action.id);
263
+ const guidance = guidanceView(detail.events, action);
269
264
  const newestFirstMessages = workItemMessages.slice().reverse();
270
265
  for (const [index, message] of newestFirstMessages.entries()) {
271
266
  const next = {
@@ -5,6 +5,7 @@ import {
5
5
  sanitizeDebugValue,
6
6
  sanitizeDiagnosticText,
7
7
  } from './debug-projection.js';
8
+ import { eventMatchesActionGeneration, runMatchesActionIdentity } from './action-identity.js';
8
9
  import { taskSpecificActionBrief } from './workflow.js';
9
10
  import { buildMainlineProjection } from './mainline-projection.js';
10
11
 
@@ -164,12 +165,15 @@ function normalizeProjectedMessage(message) {
164
165
  createdAt: count(message.createdAt),
165
166
  updatedAt: count(message.updatedAt || message.createdAt),
166
167
  ...(message.progressRevision == null ? {} : { progressRevision: count(message.progressRevision) }),
168
+ ...(message.generation == null ? {} : { generation: Math.max(1, count(message.generation) || 1) }),
169
+ ...(message.attempt == null ? {} : { attempt: Math.max(1, count(message.attempt) || 1) }),
167
170
  };
168
171
  }
169
172
 
170
173
  function actionInputMessages(action, events) {
171
174
  return (Array.isArray(events) ? events : [])
172
175
  .filter(event => event?.actionId === action?.id
176
+ && eventMatchesActionGeneration(event, action)
173
177
  && ['action.guidance_added', 'action.input_added'].includes(event.type))
174
178
  .map(event => normalizeProjectedMessage({
175
179
  id: `event:${event.id}`,
@@ -193,12 +197,17 @@ function runResponseMessage(run) {
193
197
  createdAt: count(run.startedAt),
194
198
  updatedAt: count(run.endedAt || run.startedAt),
195
199
  progressRevision: count(run.progressRevision),
200
+ generation: run.actionGeneration,
201
+ attempt: run.actionAttempt,
196
202
  });
197
203
  }
198
204
 
199
- function loopOutputMessages(action, events) {
205
+ function loopOutputMessages(action, events, matchingRunIds) {
200
206
  return (Array.isArray(events) ? events : [])
201
- .filter(event => event?.actionId === action?.id && event.type === 'run.loop_output')
207
+ .filter(event => event?.actionId === action?.id
208
+ && eventMatchesActionGeneration(event, action)
209
+ && matchingRunIds.has(event.runId)
210
+ && event.type === 'run.loop_output')
202
211
  .map(event => normalizeProjectedMessage({
203
212
  id: `event:${event.id}`,
204
213
  role: 'assistant',
@@ -206,18 +215,24 @@ function loopOutputMessages(action, events) {
206
215
  status: 'completed',
207
216
  text: event.data?.response || '',
208
217
  createdAt: event.createdAt,
218
+ generation: event.actionGeneration ?? event.data?.actionGeneration,
219
+ attempt: event.data?.actionAttempt,
209
220
  }))
210
221
  .filter(Boolean);
211
222
  }
212
223
 
213
224
  function actionMessages(action, runs, events) {
214
225
  const matchingRuns = Array.isArray(runs)
215
- ? runs.filter(run => run?.actionId === action?.id)
226
+ ? runs.filter(run => run?.actionId === action?.id && runMatchesActionIdentity(run, action))
216
227
  : [];
228
+ const matchingRunIds = new Set(matchingRuns.map(run => run.id));
217
229
  const runsWithLoopOutput = new Set((Array.isArray(events) ? events : [])
218
- .filter(event => event?.actionId === action?.id && event.type === 'run.loop_output' && event.runId)
230
+ .filter(event => event?.actionId === action?.id
231
+ && eventMatchesActionGeneration(event, action)
232
+ && matchingRunIds.has(event.runId)
233
+ && event.type === 'run.loop_output')
219
234
  .map(event => event.runId));
220
- return [...actionInputMessages(action, events), ...loopOutputMessages(action, events), ...matchingRuns
235
+ return [...actionInputMessages(action, events), ...loopOutputMessages(action, events, matchingRunIds), ...matchingRuns
221
236
  .sort((left, right) => count(left.startedAt) - count(right.startedAt))
222
237
  .filter(run => !runsWithLoopOutput.has(run.id))
223
238
  .map(run => runResponseMessage(run))
@@ -282,11 +297,12 @@ function sanitizeFailureReason(value) {
282
297
 
283
298
  function actionExecution(action, runs, events, includeBody = true) {
284
299
  const matchingRuns = Array.isArray(runs)
285
- ? runs.filter(run => run?.actionId === action?.id)
300
+ ? runs.filter(run => run?.actionId === action?.id && runMatchesActionIdentity(run, action))
286
301
  : [];
287
302
  if (matchingRuns.length === 0) {
288
303
  const inputMessageCount = Array.isArray(events) ? events.filter(event => (
289
304
  event?.actionId === action?.id
305
+ && eventMatchesActionGeneration(event, action)
290
306
  && ['action.guidance_added', 'action.input_added'].includes(event.type)
291
307
  )).length : 0;
292
308
  const messages = includeBody
@@ -334,6 +350,7 @@ function actionExecution(action, runs, events, includeBody = true) {
334
350
  ? 0
335
351
  : (Array.isArray(events) ? events : []).filter(event => (
336
352
  event?.actionId === action?.id
353
+ && eventMatchesActionGeneration(event, action)
337
354
  && ['action.guidance_added', 'action.input_added'].includes(event.type)
338
355
  )).length;
339
356
  const allMessages = includeBody ? actionMessages(action, matchingRuns, events) : [];
@@ -395,7 +412,9 @@ function projectAction(action, runs, events, includeBody = true) {
395
412
  key,
396
413
  truncateUtf8(value, includeBody ? MAX_CURRENT_BRIEF_BYTES : MAX_HISTORICAL_BRIEF_CHARS),
397
414
  ]));
398
- const matchingRuns = Array.isArray(runs) ? runs.filter(run => run?.actionId === action.id) : [];
415
+ const matchingRuns = Array.isArray(runs)
416
+ ? runs.filter(run => run?.actionId === action.id && runMatchesActionIdentity(run, action))
417
+ : [];
399
418
  const latestRun = [...matchingRuns].sort((left, right) => (
400
419
  count(right.startedAt) - count(left.startedAt) || count(right.progressRevision) - count(left.progressRevision)
401
420
  ))[0];
@@ -418,6 +437,8 @@ function projectAction(action, runs, events, includeBody = true) {
418
437
  dependsOnStageIds: Array.isArray(action.dependsOnStageIds) ? action.dependsOnStageIds : [],
419
438
  workspaceMode: action.workspaceMode || 'shared',
420
439
  requiredRole: action.requiredRole || '',
440
+ generation: Math.max(1, count(action.generation) || 1),
441
+ replacesActionId: action.replacesActionId || null,
421
442
  brief: projectedBrief,
422
443
  status: action.status,
423
444
  assignedVp,
@@ -816,6 +837,7 @@ export function projectActionMessagePage(action, runs, events, options = {}) {
816
837
  const start = Math.max(0, end - limit);
817
838
  return {
818
839
  actionId: action.id,
840
+ generation: Math.max(1, count(action.generation) || 1),
819
841
  messages: messages.slice(start, end),
820
842
  nextCursor: start > 0 ? String(start) : null,
821
843
  total: messages.length,
@@ -825,9 +847,14 @@ export function projectActionMessagePage(action, runs, events, options = {}) {
825
847
  export function projectActionRequestIndex(action, entries) {
826
848
  return {
827
849
  actionId: action.id,
828
- requests: (Array.isArray(entries) ? entries : []).map(({ run, turn }) => ({
850
+ generation: Math.max(1, count(action.generation) || 1),
851
+ requests: (Array.isArray(entries) ? entries : [])
852
+ .filter(({ run }) => runMatchesActionIdentity(run, action))
853
+ .map(({ run, turn }) => ({
829
854
  id: turn.turnId,
830
855
  runId: run.id,
856
+ generation: Math.max(1, count(run.actionGeneration) || 1),
857
+ attempt: Math.max(1, count(run.actionAttempt) || 1),
831
858
  status: run.status || 'running',
832
859
  model: run.modelSnapshot?.id || null,
833
860
  vp: run.vpSnapshot ? {
@@ -846,6 +873,7 @@ export function projectActionRequestIndex(action, entries) {
846
873
  }
847
874
 
848
875
  export function projectActionRequestDetail(action, run, history) {
876
+ if (!runMatchesActionIdentity(run, action)) return null;
849
877
  const turn = Array.isArray(history?.turns) ? history.turns[0] : null;
850
878
  if (!turn) return null;
851
879
  const sourceLoops = Array.isArray(history?.loops) ? history.loops : [];
@@ -1,6 +1,7 @@
1
1
  import { realpathSync, statSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { runMatchesActionIdentity } from './action-identity.js';
4
5
  import { WorkItemStore } from './store.js';
5
6
  import { WorkflowController } from './controller.js';
6
7
  import { WorkItemWatcher } from './watcher.js';
@@ -142,7 +143,8 @@ export class WorkCenterService {
142
143
  const detail = this.#requiredItem(payload.id);
143
144
  const action = this.#requiredAction(detail, payload.actionId);
144
145
  const entries = [];
145
- for (const run of detail.runs.filter(item => item.actionId === action.id)) {
146
+ for (const run of detail.runs.filter(item => item.actionId === action.id
147
+ && runMatchesActionIdentity(item, action))) {
146
148
  const history = await this.#debugHistory(run, { indexOnly: true });
147
149
  for (const turn of Array.isArray(history?.turns) ? history.turns : []) {
148
150
  entries.push({ run, turn });
@@ -154,7 +156,8 @@ export class WorkCenterService {
154
156
  const detail = this.#requiredItem(payload.id);
155
157
  const action = this.#requiredAction(detail, payload.actionId);
156
158
  const requestId = requiredString(payload.requestId, 'requestId');
157
- const run = detail.runs.find(item => item.actionId === action.id && item.id === payload.runId);
159
+ const run = detail.runs.find(item => item.actionId === action.id
160
+ && item.id === payload.runId && runMatchesActionIdentity(item, action));
158
161
  if (!run) throw new Error('Action request not found');
159
162
  const history = await this.#debugHistory(run, { detailTurnId: requestId });
160
163
  const projected = projectActionRequestDetail(action, run, history);
@@ -4,8 +4,9 @@ import { dirname, resolve } from 'node:path';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { normalizeEvidence } from './evidence.js';
6
6
  import { normalizeActionCheckpoint } from './action-checkpoint.js';
7
+ import { runMatchesActionIdentity } from './action-identity.js';
7
8
 
8
- const SCHEMA_VERSION = 16;
9
+ const SCHEMA_VERSION = 17;
9
10
  const OPEN_ACTION_STATUSES = "'ready','running','waiting'";
10
11
  const MAX_REUSABLE_CONTEXT_ITEMS = 12;
11
12
  const MAX_RUN_RESPONSE_CHARS = 65_536;
@@ -163,6 +164,7 @@ function mapAction(row) {
163
164
  maxAttempts: row.max_attempts,
164
165
  currentRunId: row.current_run_id || null,
165
166
  leaseEpoch: row.lease_epoch,
167
+ replacesActionId: row.replaces_action_id || null,
166
168
  createdAt: row.created_at,
167
169
  updatedAt: row.updated_at,
168
170
  };
@@ -206,6 +208,9 @@ function mapRun(row) {
206
208
  progressRevision: Math.max(0, Number(row.progress_revision) || 0),
207
209
  checkpoint: normalizeActionCheckpoint(parseJson(row.checkpoint, null)),
208
210
  acceptingInput: row.accepting_input !== 0,
211
+ actionGeneration: Math.max(1, Number(row.action_generation) || 1),
212
+ actionSpecHash: row.action_spec_hash || parseJson(row.execution_manifest, null)?.actionSpecHash || '',
213
+ actionAttempt: Math.max(1, Number(row.action_attempt) || 1),
209
214
  };
210
215
  }
211
216
 
@@ -232,6 +237,7 @@ function mapEvent(row) {
232
237
  workItemId: row.work_item_id,
233
238
  actionId: row.action_id || null,
234
239
  runId: row.run_id || null,
240
+ actionGeneration: row.action_generation == null ? null : Math.max(1, Number(row.action_generation) || 1),
235
241
  type: row.type,
236
242
  data: parseJson(row.data, {}),
237
243
  createdAt: row.created_at,
@@ -324,6 +330,7 @@ export class WorkItemStore {
324
330
  max_attempts INTEGER NOT NULL DEFAULT 2,
325
331
  current_run_id TEXT,
326
332
  lease_epoch INTEGER NOT NULL DEFAULT 0,
333
+ replaces_action_id TEXT REFERENCES actions(id) ON DELETE SET NULL,
327
334
  created_at INTEGER NOT NULL,
328
335
  updated_at INTEGER NOT NULL,
329
336
  UNIQUE(work_item_id, sequence)
@@ -363,13 +370,17 @@ export class WorkItemStore {
363
370
  total_tokens INTEGER NOT NULL DEFAULT 0,
364
371
  progress_revision INTEGER NOT NULL DEFAULT 0,
365
372
  checkpoint TEXT,
366
- accepting_input INTEGER NOT NULL DEFAULT 1
373
+ accepting_input INTEGER NOT NULL DEFAULT 1,
374
+ action_generation INTEGER NOT NULL DEFAULT 1,
375
+ action_spec_hash TEXT NOT NULL DEFAULT '',
376
+ action_attempt INTEGER NOT NULL DEFAULT 1
367
377
  );
368
378
  CREATE TABLE IF NOT EXISTS events (
369
379
  id INTEGER PRIMARY KEY AUTOINCREMENT,
370
380
  work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
371
381
  action_id TEXT,
372
382
  run_id TEXT,
383
+ action_generation INTEGER,
373
384
  type TEXT NOT NULL,
374
385
  data TEXT NOT NULL,
375
386
  created_at INTEGER NOT NULL
@@ -417,6 +428,10 @@ export class WorkItemStore {
417
428
  ON pending_action_inputs(action_id, consumed_at, event_id);
418
429
  `);
419
430
 
431
+ const storedSchemaVersion = Number(
432
+ this.db.prepare("SELECT value FROM schema_meta WHERE key = 'schema_version'").get()?.value,
433
+ ) || 0;
434
+
420
435
  // The feature shipped first as an unmerged PR, but keep the store tolerant
421
436
  // of databases created by review builds.
422
437
  if (!hasColumn(this.db, 'work_items', 'workspace_key')) {
@@ -537,12 +552,48 @@ export class WorkItemStore {
537
552
  if (!hasColumn(this.db, 'actions', 'result_run_id')) {
538
553
  this.db.exec('ALTER TABLE actions ADD COLUMN result_run_id TEXT');
539
554
  }
555
+ if (!hasColumn(this.db, 'actions', 'replaces_action_id')) {
556
+ this.db.exec('ALTER TABLE actions ADD COLUMN replaces_action_id TEXT REFERENCES actions(id) ON DELETE SET NULL');
557
+ }
558
+ if (!hasColumn(this.db, 'runs', 'action_generation')) {
559
+ this.db.exec('ALTER TABLE runs ADD COLUMN action_generation INTEGER NOT NULL DEFAULT 1');
560
+ }
561
+ if (!hasColumn(this.db, 'runs', 'action_spec_hash')) {
562
+ this.db.exec("ALTER TABLE runs ADD COLUMN action_spec_hash TEXT NOT NULL DEFAULT ''");
563
+ }
564
+ if (!hasColumn(this.db, 'runs', 'action_attempt')) {
565
+ this.db.exec('ALTER TABLE runs ADD COLUMN action_attempt INTEGER NOT NULL DEFAULT 1');
566
+ }
567
+ if (!hasColumn(this.db, 'events', 'action_generation')) {
568
+ this.db.exec('ALTER TABLE events ADD COLUMN action_generation INTEGER');
569
+ }
540
570
  if (!hasColumn(this.db, 'runs', 'context_snapshot')) {
541
571
  this.db.exec('ALTER TABLE runs ADD COLUMN context_snapshot TEXT');
542
572
  }
543
573
  if (!hasColumn(this.db, 'runs', 'execution_manifest')) {
544
574
  this.db.exec('ALTER TABLE runs ADD COLUMN execution_manifest TEXT');
545
575
  }
576
+ if (storedSchemaVersion < SCHEMA_VERSION) {
577
+ withTransaction(this.db, () => {
578
+ const updateIdentity = this.db.prepare(`UPDATE runs SET action_generation = ?,
579
+ action_spec_hash = ?, action_attempt = ? WHERE id = ?`);
580
+ const attempts = new Map();
581
+ for (const row of this.db.prepare(`SELECT id, action_id, action_generation, action_spec_hash,
582
+ execution_manifest, started_at FROM runs ORDER BY action_id, started_at, id`).all()) {
583
+ const manifest = parseJson(row.execution_manifest, null);
584
+ const generation = Math.max(1, Number(manifest?.actionGeneration) || Number(row.action_generation) || 1);
585
+ const key = `${row.action_id}\u0000${generation}`;
586
+ const attempt = (attempts.get(key) || 0) + 1;
587
+ attempts.set(key, attempt);
588
+ updateIdentity.run(
589
+ generation,
590
+ manifest?.actionSpecHash || row.action_spec_hash || '',
591
+ attempt,
592
+ row.id,
593
+ );
594
+ }
595
+ });
596
+ }
546
597
  this.db.prepare(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
547
598
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(SCHEMA_VERSION));
548
599
  }
@@ -552,12 +603,16 @@ export class WorkItemStore {
552
603
  }
553
604
 
554
605
  appendEvent(workItemId, type, data = {}, refs = {}) {
606
+ const actionGeneration = refs.actionGeneration
607
+ ?? (refs.actionId ? this.getAction(refs.actionId)?.generation : null)
608
+ ?? null;
555
609
  const result = this.db.prepare(`INSERT INTO events
556
- (work_item_id, action_id, run_id, type, data, created_at)
557
- VALUES (?, ?, ?, ?, ?, ?)`).run(
610
+ (work_item_id, action_id, run_id, action_generation, type, data, created_at)
611
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
558
612
  workItemId,
559
613
  refs.actionId || null,
560
614
  refs.runId || null,
615
+ actionGeneration,
561
616
  type,
562
617
  stringify(data),
563
618
  this.now(),
@@ -778,6 +833,7 @@ export class WorkItemStore {
778
833
  maxAttempts: Number.isInteger(input.maxAttempts) ? input.maxAttempts : 2,
779
834
  currentRunId: null,
780
835
  leaseEpoch: 0,
836
+ replacesActionId: input.replacesActionId || null,
781
837
  createdAt: now,
782
838
  updatedAt: now,
783
839
  };
@@ -785,8 +841,9 @@ export class WorkItemStore {
785
841
  this.db.prepare(`INSERT INTO actions
786
842
  (id, work_item_id, sequence, type, required_role, stage_id, assignment_policy, model_policy,
787
843
  depends_on_stage_ids, workspace_mode, changes_requested_stage_id, workspace, instruction, brief, context, contract_revision,
788
- generation, spec_hash, result_run_id, status, attempt, max_attempts, current_run_id, lease_epoch, created_at, updated_at)
789
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
844
+ generation, spec_hash, result_run_id, status, attempt, max_attempts, current_run_id, lease_epoch,
845
+ replaces_action_id, created_at, updated_at)
846
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?, ?)`).run(
790
847
  action.id,
791
848
  workItemId,
792
849
  action.sequence,
@@ -809,6 +866,7 @@ export class WorkItemStore {
809
866
  action.status,
810
867
  action.attempt,
811
868
  action.maxAttempts,
869
+ action.replacesActionId,
812
870
  now,
813
871
  now,
814
872
  );
@@ -951,6 +1009,8 @@ export class WorkItemStore {
951
1009
  const nextWorkspaceMode = workspaceMode || action.workspaceMode;
952
1010
  const specChanged = nextWorkspaceMode !== action.workspaceMode;
953
1011
  const nextAction = { ...action, workspaceMode: nextWorkspaceMode };
1012
+ const nextGeneration = action.generation + (specChanged ? 1 : 0);
1013
+ const nextSpecHash = specChanged ? actionSpecHash(nextAction) : action.specHash;
954
1014
  const now = this.now();
955
1015
  if (action.workspaceMode === 'isolated-write' && nextWorkspaceMode === 'shared') {
956
1016
  const workItem = this.getWorkItem(action.workItemId);
@@ -973,7 +1033,7 @@ export class WorkItemStore {
973
1033
  stringify(workspace),
974
1034
  nextWorkspaceMode,
975
1035
  specChanged ? 1 : 0,
976
- specChanged ? actionSpecHash(nextAction) : action.specHash,
1036
+ nextSpecHash,
977
1037
  specChanged ? 1 : 0,
978
1038
  now,
979
1039
  actionId,
@@ -982,6 +1042,23 @@ export class WorkItemStore {
982
1042
  expectedGeneration,
983
1043
  );
984
1044
  if (Number(changed.changes) !== 1) return null;
1045
+ if (specChanged) {
1046
+ const rebound = this.db.prepare(`UPDATE runs SET action_generation = ?, action_spec_hash = ?
1047
+ WHERE id = ? AND action_id = ? AND owner_boot_id = ? AND lease_epoch = ? AND status = 'running'
1048
+ AND action_generation = ? AND action_spec_hash = ?`).run(
1049
+ nextGeneration,
1050
+ nextSpecHash,
1051
+ runId,
1052
+ actionId,
1053
+ ownerBootId,
1054
+ leaseEpoch,
1055
+ action.generation,
1056
+ action.specHash,
1057
+ );
1058
+ if (Number(rebound.changes) !== 1) {
1059
+ throw new Error('Work Center could not rebind the owned Run after workspace fallback');
1060
+ }
1061
+ }
985
1062
 
986
1063
  if (action.workspaceMode === 'isolated-write' && nextWorkspaceMode === 'shared') {
987
1064
  const pendingRows = this.db.prepare(`SELECT * FROM actions
@@ -1705,6 +1782,10 @@ export class WorkItemStore {
1705
1782
  const priorProgress = this.db.prepare(`SELECT MAX(progress_revision) AS value FROM runs
1706
1783
  WHERE action_id = ?`).get(row.id);
1707
1784
  const progressRevision = Math.max(0, Number(priorProgress?.value) || 0) + 1;
1785
+ const actionGeneration = Math.max(1, Number(row.generation) || 1);
1786
+ const priorAttempt = this.db.prepare(`SELECT MAX(action_attempt) AS value FROM runs
1787
+ WHERE action_id = ? AND action_generation = ?`).get(row.id, actionGeneration);
1788
+ const actionAttempt = Math.max(0, Number(priorAttempt?.value) || 0) + 1;
1708
1789
  const changedAction = this.db.prepare(`UPDATE actions SET status = 'running', attempt = attempt + 1,
1709
1790
  current_run_id = ?, lease_epoch = ?, updated_at = ?
1710
1791
  WHERE id = ? AND status = 'ready' AND current_run_id IS NULL`).run(
@@ -1726,9 +1807,19 @@ export class WorkItemStore {
1726
1807
  if (Number(changedWorkItem.changes) !== 1) throw new Error('WorkItem claim lost its Action fence');
1727
1808
  this.db.prepare(`INSERT INTO runs
1728
1809
  (id, action_id, work_item_id, owner_boot_id, lease_epoch, status, started_at,
1729
- expires_at, evidence, progress_revision)
1730
- VALUES (?, ?, ?, ?, ?, 'running', ?, ?, '[]', ?)`).run(
1731
- runId, row.id, row.work_item_id, ownerBootId, leaseEpoch, now, now + leaseMs, progressRevision,
1810
+ expires_at, evidence, progress_revision, action_generation, action_spec_hash, action_attempt)
1811
+ VALUES (?, ?, ?, ?, ?, 'running', ?, ?, '[]', ?, ?, ?, ?)`).run(
1812
+ runId,
1813
+ row.id,
1814
+ row.work_item_id,
1815
+ ownerBootId,
1816
+ leaseEpoch,
1817
+ now,
1818
+ now + leaseMs,
1819
+ progressRevision,
1820
+ actionGeneration,
1821
+ row.spec_hash || '',
1822
+ actionAttempt,
1732
1823
  );
1733
1824
  this.appendEvent(row.work_item_id, 'run.claimed', { ownerBootId, leaseEpoch }, {
1734
1825
  actionId: row.id, runId,
@@ -1903,9 +1994,13 @@ export class WorkItemStore {
1903
1994
  }
1904
1995
 
1905
1996
  getActionResumeContext(actionId, excludeRunId = null) {
1997
+ const action = this.getAction(actionId);
1998
+ if (!action) return null;
1906
1999
  const runs = this.db.prepare(`SELECT * FROM runs
1907
2000
  WHERE action_id = ? AND id != ? AND status IN ('interrupted', 'retryable')
1908
- ORDER BY ended_at DESC, started_at DESC`).all(actionId, excludeRunId || '').map(mapRun);
2001
+ ORDER BY ended_at DESC, started_at DESC`).all(actionId, excludeRunId || '')
2002
+ .map(mapRun)
2003
+ .filter(run => runMatchesActionIdentity(run, action));
1909
2004
  if (runs.length === 0) return null;
1910
2005
  const latest = runs[0];
1911
2006
  const response = runs.find(run => run.response)?.response || '';