@knpkv/herdr-work 0.5.3 → 0.6.0

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 (55) hide show
  1. package/README.md +86 -0
  2. package/dist/display-state.d.ts +10 -0
  3. package/dist/display-state.d.ts.map +1 -0
  4. package/dist/display-state.js +5 -0
  5. package/dist/display-state.js.map +1 -0
  6. package/dist/errors.d.ts +21 -0
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +12 -0
  9. package/dist/errors.js.map +1 -1
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/internal/legacy-lane-claim.d.ts +74 -0
  15. package/dist/internal/legacy-lane-claim.d.ts.map +1 -0
  16. package/dist/internal/legacy-lane-claim.js +81 -0
  17. package/dist/internal/legacy-lane-claim.js.map +1 -0
  18. package/dist/internal/session-index.d.ts +18 -0
  19. package/dist/internal/session-index.d.ts.map +1 -0
  20. package/dist/internal/session-index.js +23 -0
  21. package/dist/internal/session-index.js.map +1 -0
  22. package/dist/internal/terminal-reconcile.d.ts +29 -0
  23. package/dist/internal/terminal-reconcile.d.ts.map +1 -0
  24. package/dist/internal/terminal-reconcile.js +79 -0
  25. package/dist/internal/terminal-reconcile.js.map +1 -0
  26. package/dist/model.d.ts +909 -29
  27. package/dist/model.d.ts.map +1 -1
  28. package/dist/model.js +236 -7
  29. package/dist/model.js.map +1 -1
  30. package/dist/observed.d.ts +77 -0
  31. package/dist/observed.d.ts.map +1 -0
  32. package/dist/observed.js +240 -0
  33. package/dist/observed.js.map +1 -0
  34. package/dist/projection.d.ts +192 -12
  35. package/dist/projection.d.ts.map +1 -1
  36. package/dist/service.d.ts +121 -992
  37. package/dist/service.d.ts.map +1 -1
  38. package/dist/service.js +13 -3
  39. package/dist/service.js.map +1 -1
  40. package/dist/sql.d.ts.map +1 -1
  41. package/dist/sql.js +76 -3
  42. package/dist/sql.js.map +1 -1
  43. package/dist/store.d.ts +193 -11
  44. package/dist/store.d.ts.map +1 -1
  45. package/dist/store.js +687 -37
  46. package/dist/store.js.map +1 -1
  47. package/dist/view.d.ts.map +1 -1
  48. package/dist/view.js +169 -103
  49. package/dist/view.js.map +1 -1
  50. package/dist/work-triage.d.ts +72 -0
  51. package/dist/work-triage.d.ts.map +1 -0
  52. package/dist/work-triage.js +114 -0
  53. package/dist/work-triage.js.map +1 -0
  54. package/package.json +3 -3
  55. package/src/styles.css +229 -237
package/dist/store.js CHANGED
@@ -1,16 +1,20 @@
1
- import { agentConnectTarget, fleetResponseBodyMaxBytes, workReassignActivitySummary } from "@knpkv/herdr-fleet";
1
+ import { agentConnectTarget, fleetResponseBodyMaxBytes, workAbandonActivitySummary, workReassignActivitySummary } from "@knpkv/herdr-fleet";
2
2
  import { openPrivateSqlite } from "@knpkv/herdr-fleet/sqlite";
3
- import { Clock, Crypto, Effect, Equal, Option, Schema } from "effect";
3
+ import { Clock, Crypto, Effect, Equal, Option, Result, Schema } from "effect";
4
4
  import { Hex } from "effect/encoding";
5
5
  import { makeWorkAgentBinding } from "./agent-binding.js";
6
- import { WorkAdmissionConflictError, WorkAgentBindingAuthorityError, WorkAgentBindingConflictError, WorkCheckpointConflictError, WorkCoordinatorHandoffConflictError, WorkDecisionAuthorityConflictError, WorkDecisionHandoffConflictError, WorkDecisionRevisionConflictError, WorkGoalAgentTargetConflictError, WorkGoalBindingRequiresAgentError, WorkGoalOwnerMismatchError, WorkGoalReassignmentConflictError, WorkGoalRevisionConflictError, WorkLaneClaimConflictError, WorkLaneGoalConflictError, WorkLaneOperationConflictError, WorkProjectionError, WorkPullRequestLinkError, WorkStoreError, WorkTransactionConflictError } from "./errors.js";
6
+ import { WorkAdmissionConflictError, WorkAgentBindingAuthorityError, WorkAgentBindingConflictError, WorkCheckpointConflictError, WorkCoordinatorHandoffConflictError, WorkDecisionAuthorityConflictError, WorkDecisionHandoffConflictError, WorkDecisionRevisionConflictError, WorkGoalAbandonmentConflictError, WorkGoalAgentTargetConflictError, WorkGoalBindingRequiresAgentError, WorkGoalLaneActiveError, WorkGoalOwnerMismatchError, WorkGoalReassignmentConflictError, WorkGoalRevisionConflictError, WorkGoalTerminalError, WorkLaneClaimConflictError, WorkLaneGoalConflictError, WorkLaneOperationConflictError, WorkProjectionError, WorkPullRequestLinkError, WorkStoreError, WorkTransactionConflictError } from "./errors.js";
7
7
  import { validateGoalFamilyHistory } from "./goal-family.js";
8
8
  import { agentBindingAdmissionError, workAgentBindingLaneOperationMaxBytes, workAgentBindingLaneOperationMaxRecords, workAgentBindingMaximumSnapshotBytes, workAgentBindingSnapshotEnvelopeMaxBytes, workMaximumSnapshotBytesForHistory } from "./internal/agent-binding-admission.js";
9
9
  import { AgentBindingGoalEventRow, AgentBindingLaneOperationRow, agentBindingReadbackError, AgentBindingRow, decodeAgentBindingGoalEvent, decodeAgentBindingRow, laneOperationReadbackError } from "./internal/agent-binding-readback.js";
10
10
  import { CoordinatorCommandRow, CoordinatorLifecycleDispatchRow, CoordinatorLifecycleEventRow, coordinatorLifecycleRunningAt, CoordinatorRouteDiscriminatorRow, coordinatorRouteRequiresWorkLink, requireCoordinatorFailedLunaAuthority, requireCoordinatorLifecycleAuthority, requireCoordinatorRouteAuthority, requireCoordinatorRouteBinding } from "./internal/coordinator-authority.js";
11
11
  import { currentDecisionHandoffEquivalent, CurrentMetadataWorkLink, decodePreviousDecisionHandoff, previousDecisionHandoffEquivalent, PreviousWorkDecisionHandoff, upgradePreviousDecisionHandoff, workDispatchLineageContainedBy, workDispatchLineageEquivalent } from "./internal/decision-handoff-migration.js";
12
12
  import { workHistoryError } from "./internal/history-validation.js";
13
- import { WorkAdmissionTarget, WorkAgentBinding, WorkAgentBindingRequest, WorkCoordinatorSessionId, WorkDecisionHandoff, WorkDispatchHandoff, WorkExistingGoalRecovery, WorkGoal, WorkGoalCheckpoint, WorkGoalId, WorkGoalReassigned, WorkGoalReassignment, workHistoryMaxEvents, WorkLaneClaim, WorkLaneClaimed, WorkProspectiveAdmission, WorkPullRequestLink, WorkRecoveryTarget, workSnapshotMaxGoals } from "./model.js";
13
+ import { LaneOperationLedgerRow, LaneOperationTotalsRow, planLegacyLaneOperations, resolveLegacyLaneClaim } from "./internal/legacy-lane-claim.js";
14
+ import { enforcesOneHandoffPerSession, SessionIndexDefinition, sessionIndexDefinitionQuery } from "./internal/session-index.js";
15
+ import { goalActivityLimit, terminalCandidates, terminalCheckpoint, withoutBlockers } from "./internal/terminal-reconcile.js";
16
+ import { admissionEvidence, isTerminalWorkState, WorkAdmissionTarget, WorkAgentBinding, WorkAgentBindingRequest, WorkCoordinatorSessionId, WorkDecisionHandoff, WorkDispatchHandoff, WorkExistingGoalRecovery, WorkGoal, WorkGoalAbandoned, WorkGoalAbandonment, WorkGoalCheckpoint, WorkGoalId, WorkGoalReassigned, WorkGoalReassignment, workHistoryMaxEvents, WorkLaneClaim, WorkLaneClaimed, WorkObservationEnvelope, workObservationMaxSkewMillis, WorkObservationSubject, WorkObservedAdmission, WorkObservedFact, workObservedFactMaxBytes, workObservedFactMaxRecords, WorkObservedFailure, WorkProspectiveAdmission, WorkPullRequestLink, workReconcilerHeadroom, WorkRecoveryTarget, workSnapshotMaxGoals } from "./model.js";
17
+ import { asciiLower, canonicalSubject, observationSubject, reconcilerEventPrefix } from "./observed.js";
14
18
  const StoredEventRow = Schema.Struct({ record: Schema.String });
15
19
  const StoredEventRows = Schema.Array(StoredEventRow);
16
20
  const StoredEventWithTransactionRow = Schema.Struct({
@@ -138,6 +142,31 @@ const legacyDecisionEquivalent = Schema.toEquivalence(LegacyDecisionRecord);
138
142
  const TransactionId = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256), Schema.isPattern(/^(?:[^\uD800-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF])*$/));
139
143
  const storeError = (operation) => (cause) => new WorkStoreError({ cause, operation });
140
144
  const fromPrivateDatabaseError = (error) => new WorkStoreError({ cause: error.cause, operation: error.operation });
145
+ /**
146
+ * Files written before the session index was unique carry a plain index under
147
+ * the same name, which `CREATE UNIQUE INDEX IF NOT EXISTS` leaves in place.
148
+ * Replace it, failing closed when two handoffs already share a session. Runs
149
+ * inside `WorkStore.open`'s schema transaction, so a failure leaves the plain
150
+ * index in place.
151
+ */
152
+ const requireUniqueSessionIndex = (database) => {
153
+ const sessionIndex = Schema.decodeUnknownSync(Schema.Array(SessionIndexDefinition))(database.prepare(sessionIndexDefinitionQuery).all())[0];
154
+ if (sessionIndex === undefined || enforcesOneHandoffPerSession(sessionIndex))
155
+ return;
156
+ // NULL sessions never collide under a UNIQUE index, so they are not duplicates.
157
+ const duplicateGroups = `SELECT session_id AS sessionId, COUNT(*) AS rows, group_concat(handoff_id) AS handoffIds
158
+ FROM work_decision_handoffs WHERE session_id IS NOT NULL
159
+ GROUP BY session_id HAVING COUNT(*) > 1`;
160
+ const duplicates = database.prepare(`${duplicateGroups} LIMIT 10`).all();
161
+ if (duplicates.length > 0) {
162
+ const total = database.prepare(`SELECT COUNT(*) AS sessions FROM (${duplicateGroups})`).get();
163
+ throw new WorkStoreError({ cause: { duplicates, total }, operation: "open.migrate.session-index" });
164
+ }
165
+ database.exec(`
166
+ DROP INDEX work_decision_handoffs_session;
167
+ CREATE UNIQUE INDEX work_decision_handoffs_session ON work_decision_handoffs (session_id);
168
+ `);
169
+ };
141
170
  /**
142
171
  * Upgrades pre-session Work tables in place. Runs inside `WorkStore.open`'s
143
172
  * schema transaction, so a failure here or in a later schema step leaves the
@@ -371,6 +400,8 @@ const migrateLegacyAuthorityTables = (database) => {
371
400
  });
372
401
  })
373
402
  : [];
403
+ // Lanes the running bindings of legacy handoffs recorded; see resolveLegacyLaneClaim.
404
+ const bindingLanes = [];
374
405
  const decisions = decisionColumns.length > 0 && !decisionColumns.includes("session_id")
375
406
  ? Schema.decodeUnknownSync(Schema.Array(LegacyDecisionStoredRow))(database.prepare("SELECT handoff_id AS handoffId, record FROM work_decision_handoffs").all()).map((row) => {
376
407
  const legacy = Schema.decodeUnknownSync(LegacyDecisionRecord)(JSON.parse(row.record));
@@ -454,6 +485,7 @@ const migrateLegacyAuthorityTables = (database) => {
454
485
  operation: "open.migrate.legacy-handoff-lane-revision"
455
486
  });
456
487
  }
488
+ bindingLanes.push(bindingDecision.binding.lane);
457
489
  requireBindingCompanions(bindingDecision.binding, "open.migrate.legacy-agent-binding");
458
490
  requireLifecycleAuthority(bindingDecision.binding.request.dispatchRequestId, bindingDecision.binding.checkpoint.occurredAt, "open.migrate.legacy-agent-binding.lifecycle");
459
491
  return Schema.decodeUnknownSync(WorkDecisionHandoff)({
@@ -468,13 +500,47 @@ const migrateLegacyAuthorityTables = (database) => {
468
500
  });
469
501
  })
470
502
  : [];
503
+ const migratedClaims = lanes.map((lane) => resolveLegacyLaneClaim(lane, bindingLanes));
471
504
  if (laneColumns.length > 0 && !laneColumns.includes("goal_id")) {
472
505
  database.exec("ALTER TABLE work_lane_claims ADD COLUMN goal_id TEXT");
473
506
  database.exec("ALTER TABLE work_lane_claims ADD COLUMN operation_id TEXT");
474
507
  database.exec("ALTER TABLE work_lane_claims ADD COLUMN phase TEXT");
475
508
  const update = database.prepare("UPDATE work_lane_claims SET goal_id = ?, operation_id = ?, phase = ?, record = ? WHERE lane_id = ?");
476
- for (const lane of lanes)
509
+ // An existing lane-operation ledger is not backfilled later, so record each
510
+ // migrated claim's operation here; the claim's readback requires it.
511
+ // Without a ledger, schema creation below creates it and backfills every
512
+ // claim, so the same bound applies to an empty ledger.
513
+ const hasLedger = tables.includes("work_lane_operations");
514
+ const operations = planLegacyLaneOperations(migratedClaims.map(({ lane }) => lane), new Map(hasLedger
515
+ ? migratedClaims.flatMap(({ lane }) => {
516
+ const matches = database.prepare(`SELECT operation_id AS operationId, lane_id AS laneId, goal_id AS goalId, phase, revision, record
517
+ FROM work_lane_operations WHERE CAST(operation_id AS TEXT) = ?`).all(lane.operationId);
518
+ const rows = Schema.decodeUnknownSync(Schema.Array(LaneOperationLedgerRow))(matches);
519
+ // A key stored as a blob, or twice, is a collision even when its bytes match.
520
+ const found = rows.find(({ operationId }) => operationId !== lane.operationId) ?? rows[0];
521
+ return found === undefined ? [] : [[lane.operationId, found]];
522
+ })
523
+ : []), hasLedger
524
+ ? Schema.decodeUnknownSync(LaneOperationTotalsRow)(database.prepare(`SELECT COUNT(*) AS count, COALESCE(SUM(
525
+ length(CAST(operation_id AS BLOB)) + length(CAST(record AS BLOB))), 0) AS bytes
526
+ FROM work_lane_operations`).get())
527
+ : { bytes: 0, count: 0 }, { bytes: workLaneOperationMaxBytes, records: workLaneOperationMaxRecords });
528
+ if (operations._tag === "collision") {
529
+ throw new WorkStoreError({ cause: operations, operation: "open.migrate.lane-operation-collision" });
530
+ }
531
+ if (operations._tag === "capacity") {
532
+ throw new WorkStoreError({ cause: operations, operation: "open.migrate.lane-operation-capacity" });
533
+ }
534
+ for (const { lane } of migratedClaims) {
477
535
  update.run(lane.goalId, lane.operationId, lane.phase, JSON.stringify(lane), lane.laneId);
536
+ }
537
+ if (hasLedger && operations.inserts.length > 0) {
538
+ const recordOperation = database.prepare(`INSERT INTO work_lane_operations
539
+ (operation_id, lane_id, goal_id, phase, revision, record) VALUES (?, ?, ?, ?, ?, ?)`);
540
+ for (const lane of operations.inserts) {
541
+ recordOperation.run(lane.operationId, lane.laneId, lane.goalId, lane.phase, lane.revision, JSON.stringify(lane));
542
+ }
543
+ }
478
544
  }
479
545
  if (decisionColumns.length > 0 && !decisionColumns.includes("session_id")) {
480
546
  database.exec("ALTER TABLE work_decision_handoffs ADD COLUMN session_id TEXT");
@@ -721,6 +787,16 @@ const migrateLegacyAuthorityTables = (database) => {
721
787
  }
722
788
  }
723
789
  }
790
+ // Reported after capacity: lane CAS allows one binding per revision, so
791
+ // several mean the file is inconsistent rather than merely large.
792
+ const ambiguousClaims = migratedClaims.filter(({ _tag }) => _tag === "ambiguous");
793
+ if (ambiguousClaims.length > 0) {
794
+ throw new WorkStoreError({ cause: { claims: ambiguousClaims }, operation: "open.migrate.lane-binding-ambiguous" });
795
+ }
796
+ const mismatchedClaims = migratedClaims.filter(({ _tag }) => _tag === "mismatch");
797
+ if (mismatchedClaims.length > 0) {
798
+ throw new WorkStoreError({ cause: { claims: mismatchedClaims }, operation: "open.migrate.lane-binding-mismatch" });
799
+ }
724
800
  if (tables.includes("orchestrator_dispatch_metadata") && coordinatorDispatchColumns.includes("is_routed")) {
725
801
  const pageSize = 512;
726
802
  let cursor = null;
@@ -794,6 +870,7 @@ const readDecisionLedgerTotals = (database) => Schema.decodeUnknownSync(Decision
794
870
  FROM work_decision_totals WHERE singleton = 1`).get());
795
871
  const readLaneOperationLedgerTotals = (database) => Schema.decodeUnknownSync(LaneOperationLedgerTotalsRow)(database.prepare(`SELECT operation_count AS operationCount, operation_bytes AS operationBytes
796
872
  FROM work_lane_operation_totals WHERE singleton = 1`).get());
873
+ const ObservationIdRow = Schema.Struct({ observationId: Schema.String });
797
874
  const ReassignmentRow = Schema.Struct({ approvalJobId: Schema.String, goalId: Schema.String, record: Schema.String });
798
875
  /** Applies a reassignment's agent choice; `keep` is only reached when the goal has no target. */
799
876
  const withReassignedAgent = (goal, toAgent) => {
@@ -946,11 +1023,11 @@ const admissionState = (database, target) => {
946
1023
  (claim.worktree === target.worktree && claim.laneId !== target.laneId)) ||
947
1024
  authority.latest.some(({ request }) => request.worker.agentId === target.worker.agentId && request.laneId !== target.laneId) ||
948
1025
  // A session id stays claimed by its lane even after a reassignment supersedes that binding.
949
- bindings.some(({ request }) => (request.prospectiveAdmission?.sessionId === target.sessionId && request.laneId !== target.laneId) ||
1026
+ bindings.some(({ request }) => (admissionEvidence(request)?.sessionId === target.sessionId && request.laneId !== target.laneId) ||
950
1027
  (request.existingGoalRecovery?.sessionId === target.sessionId && request.laneId !== target.laneId)) || authority.ambiguousLanes.has(target.laneId);
951
1028
  const exact = goal !== undefined && lane !== undefined && binding !== undefined &&
952
- goal.goal.id === target.goalId && goal.goal.state !== "completed" &&
953
- goal.goal.state !== "deployed" && goal.goal.goalFamily?.role === "canonical" &&
1029
+ goal.goal.id === target.goalId && !isTerminalWorkState(goal.goal.state) &&
1030
+ goal.goal.goalFamily?.role === "canonical" &&
954
1031
  goal.goal.goalFamily.canonicalGoalId === target.goalId &&
955
1032
  Equal.equals(goal.goal.owner, target.owner) &&
956
1033
  lane.goalId === target.goalId && lane.head === target.head &&
@@ -958,9 +1035,9 @@ const admissionState = (database, target) => {
958
1035
  lane.phase !== "shipped" && Equal.equals(lane.owner, target.owner) &&
959
1036
  binding.lane.laneId === target.laneId &&
960
1037
  Equal.equals(binding.request.worker, target.worker) &&
961
- (binding.request.prospectiveAdmission === undefined || (binding.request.prospectiveAdmission.sessionId === target.sessionId &&
962
- binding.request.prospectiveAdmission.workAssignment === target.expectedWork &&
963
- binding.request.prospectiveAdmission.baseHead === target.baseHead)) &&
1038
+ (admissionEvidence(binding.request) === undefined || (admissionEvidence(binding.request)?.sessionId === target.sessionId &&
1039
+ admissionEvidence(binding.request)?.workAssignment === target.expectedWork &&
1040
+ admissionEvidence(binding.request)?.baseHead === target.baseHead)) &&
964
1041
  (binding.request.existingGoalRecovery === undefined ||
965
1042
  (binding.request.existingGoalRecovery.sessionId === target.sessionId &&
966
1043
  binding.request.existingGoalRecovery.workAssignment === target.expectedWork &&
@@ -987,7 +1064,7 @@ const admissionState = (database, target) => {
987
1064
  entry.goalFamily?.canonicalGoalId === target.goalId ||
988
1065
  entry.agentHierarchy?.agent.agentId === target.worker.agentId) || lanes.entries.some(({ claim }) => claim.laneId === target.laneId || claim.goalId === target.goalId ||
989
1066
  claim.worktree === target.worktree) || authority.latest.some(({ request }) => request.worker.agentId === target.worker.agentId) ||
990
- bindings.some(({ request }) => request.prospectiveAdmission?.sessionId === target.sessionId ||
1067
+ bindings.some(({ request }) => admissionEvidence(request)?.sessionId === target.sessionId ||
991
1068
  request.existingGoalRecovery?.sessionId === target.sessionId);
992
1069
  if (conflicts) {
993
1070
  return { _tag: "conflict", target, reason: "durable PR, goal, lane, worker, or worktree is already bound" };
@@ -1124,8 +1201,7 @@ const recoveryState = (database, target) => {
1124
1201
  goal.connectTarget !== null ||
1125
1202
  (goal.agentHierarchy !== undefined && goal.agentHierarchy !== null) ||
1126
1203
  (goal.review?.url !== null && goal.review?.url !== undefined && goal.review.url !== target.reviewUrl) ||
1127
- goal.state === "completed" ||
1128
- goal.state === "deployed")) {
1204
+ isTerminalWorkState(goal.state))) {
1129
1205
  return conflict("canonical goal history is linked, terminal, or belongs to another owner");
1130
1206
  }
1131
1207
  const bindingRows = Schema.decodeUnknownSync(AgentBindingRows)(database
@@ -1158,7 +1234,7 @@ const recoveryState = (database, target) => {
1158
1234
  operations.claims.some(({ goalId, laneId, worktree }) => goalId === target.goalId || laneId === target.laneId || worktree === target.worktree) ||
1159
1235
  bindings.some(({ request }) => request.laneId === target.laneId ||
1160
1236
  request.worker.agentId === target.worker.agentId ||
1161
- request.prospectiveAdmission?.sessionId === target.sessionId ||
1237
+ admissionEvidence(request)?.sessionId === target.sessionId ||
1162
1238
  request.existingGoalRecovery?.sessionId === target.sessionId) ||
1163
1239
  decisions.some(({ record }) => {
1164
1240
  const handoff = Schema.decodeUnknownSync(WorkDecisionHandoff)(JSON.parse(record));
@@ -1186,6 +1262,140 @@ const readRecoveryState = (database, target) => {
1186
1262
  throw cause;
1187
1263
  }
1188
1264
  };
1265
+ const ReassignmentApprovalRow = Schema.Struct({ approvalJobId: Schema.String, goalId: Schema.String });
1266
+ const ReconcilerEventRow = Schema.Struct({ eventId: Schema.String, goalId: Schema.String });
1267
+ const ObservedFactRow = Schema.Struct({
1268
+ subject: Schema.String,
1269
+ observationId: Schema.String,
1270
+ observedAt: Schema.Number,
1271
+ confirmedAt: Schema.Number,
1272
+ record: Schema.String
1273
+ });
1274
+ const StoredFactRow = Schema.Struct({ observationId: Schema.String, confirmedAt: Schema.Number });
1275
+ const FactTotalsRow = Schema.Struct({ count: Schema.Number, bytes: Schema.Number });
1276
+ const EvictionRow = Schema.Struct({ subject: Schema.String, bytes: Schema.Number });
1277
+ const canonicalObservation = (observation) => observation._tag === "agent"
1278
+ ? { ...observation, host: asciiLower(observation.host) }
1279
+ : { ...observation, repository: asciiLower(observation.repository) };
1280
+ /**
1281
+ * Applies each observation in one transaction, then evicts the oldest rows
1282
+ * until facts and failures are each within their bounds.
1283
+ *
1284
+ * - The same facts again keep their first-seen time and move `confirmed_at`.
1285
+ * - Different facts replace the stored ones, unless the stored ones were
1286
+ * confirmed at or after this observation (then it is stale).
1287
+ * - A good read ends the subject's run of failures. A failure starts a run,
1288
+ * or keeps the running one's `since`; one older than the last good read is stale.
1289
+ */
1290
+ const writeObservations = (database, prepared) => {
1291
+ database.exec("BEGIN IMMEDIATE");
1292
+ try {
1293
+ const readFact = database.prepare(`SELECT observation_id AS observationId, confirmed_at AS confirmedAt
1294
+ FROM work_observed_facts WHERE subject = ?`);
1295
+ const upsertFact = database.prepare(`INSERT INTO work_observed_facts (subject, observation_id, observed_at, confirmed_at, record)
1296
+ VALUES (?, ?, ?, ?, ?)
1297
+ ON CONFLICT (subject) DO UPDATE SET observation_id = excluded.observation_id,
1298
+ observed_at = excluded.observed_at, confirmed_at = excluded.confirmed_at, record = excluded.record`);
1299
+ const confirmFact = database.prepare("UPDATE work_observed_facts SET confirmed_at = max(confirmed_at, ?) WHERE subject = ?");
1300
+ // A good read ends a run of failures only if it is newer than the run's
1301
+ // latest failure.
1302
+ const clearFailures = database.prepare("DELETE FROM work_observed_failures WHERE subject = ? AND last_at <= ?");
1303
+ // We keep only the run's first and latest failure, so a run split by a
1304
+ // delayed good read restarts at its latest failure: a real failed read.
1305
+ const trimFailures = database.prepare("UPDATE work_observed_failures SET since = last_at WHERE subject = ? AND since <= ? AND last_at > ?");
1306
+ const endFailures = {
1307
+ run: (subject, observedAt) => {
1308
+ clearFailures.run(subject, observedAt);
1309
+ trimFailures.run(subject, observedAt, observedAt);
1310
+ }
1311
+ };
1312
+ // `since` is the earliest failure of the run; source and reason come from
1313
+ // the latest one, whatever order the failures arrive in.
1314
+ const recordFailure = database.prepare(`INSERT INTO work_observed_failures (subject, source, reason, since, last_at) VALUES (?, ?, ?, ?, ?)
1315
+ ON CONFLICT (subject) DO UPDATE SET
1316
+ since = min(since, excluded.since),
1317
+ source = CASE WHEN excluded.last_at >= last_at THEN excluded.source ELSE source END,
1318
+ reason = CASE WHEN excluded.last_at >= last_at THEN excluded.reason ELSE reason END,
1319
+ last_at = max(last_at, excluded.last_at)`);
1320
+ const outcomes = prepared.map((item) => {
1321
+ if (item._tag === "future")
1322
+ return { _tag: "stale", subject: item.subject };
1323
+ const row = readFact.get(item.subject);
1324
+ const stored = row === undefined ? undefined : Schema.decodeUnknownSync(StoredFactRow)(row);
1325
+ if (item._tag === "unknown") {
1326
+ if (stored !== undefined && stored.confirmedAt >= item.observedAt) {
1327
+ return { _tag: "stale", subject: item.subject };
1328
+ }
1329
+ recordFailure.run(item.subject, item.source, item.reason, item.observedAt, item.observedAt);
1330
+ return { _tag: "unknown", reason: item.reason, subject: item.subject };
1331
+ }
1332
+ if (stored?.observationId === item.observationId) {
1333
+ confirmFact.run(item.observedAt, item.subject);
1334
+ endFailures.run(item.subject, item.observedAt);
1335
+ return { _tag: "unchanged", subject: item.subject };
1336
+ }
1337
+ if (stored !== undefined && stored.confirmedAt >= item.observedAt)
1338
+ return { _tag: "stale", subject: item.subject };
1339
+ upsertFact.run(item.subject, item.observationId, item.observedAt, item.observedAt, item.record);
1340
+ endFailures.run(item.subject, item.observedAt);
1341
+ return { _tag: "stored", subject: item.subject };
1342
+ });
1343
+ // Totals are read once and kept current as rows go, so eviction is one
1344
+ // indexed lookup and delete per row, not a full rescan per row.
1345
+ const evict = (table, age, payload) => {
1346
+ const size = `length(CAST(subject AS BLOB)) + length(CAST(${payload} AS BLOB))`;
1347
+ const totals = Schema.decodeUnknownSync(FactTotalsRow)(database.prepare(`SELECT COUNT(*) AS count, COALESCE(SUM(${size}), 0) AS bytes FROM ${table}`).get());
1348
+ const oldest = database.prepare(`SELECT subject, ${size} AS bytes FROM ${table} ORDER BY ${age} ASC, subject ASC LIMIT 1`);
1349
+ const remove = database.prepare(`DELETE FROM ${table} WHERE subject = ?`);
1350
+ let { bytes, count } = totals;
1351
+ let evicted = 0;
1352
+ while (count > workObservedFactMaxRecords || bytes > workObservedFactMaxBytes) {
1353
+ const row = Schema.decodeUnknownSync(EvictionRow)(oldest.get());
1354
+ remove.run(row.subject);
1355
+ count -= 1;
1356
+ bytes -= row.bytes;
1357
+ evicted += 1;
1358
+ }
1359
+ return evicted;
1360
+ };
1361
+ // The least recently read rows go first: a fact by its last confirmation,
1362
+ // a failure by its latest failed read.
1363
+ const evicted = evict("work_observed_facts", "confirmed_at", "record") +
1364
+ evict("work_observed_failures", "last_at", "reason");
1365
+ database.exec("COMMIT");
1366
+ return { evicted, outcomes };
1367
+ }
1368
+ catch (cause) {
1369
+ if (database.isTransaction)
1370
+ database.exec("ROLLBACK");
1371
+ throw cause;
1372
+ }
1373
+ };
1374
+ const admissionSummary = (provenance) => provenance._tag === "approved"
1375
+ ? `Prospective admission of an existing owner by approved Fleet job ${provenance.approvalJobId}`
1376
+ : `Admission of an observed worker by the reconciler from observation ${provenance.observationId}`;
1377
+ /** The binding-request key that records how a worker was admitted. */
1378
+ const admissionRecord = (provenance, evidence) => provenance._tag === "approved"
1379
+ ? {
1380
+ prospectiveAdmission: {
1381
+ ...evidence,
1382
+ approvalJobId: provenance.approvalJobId,
1383
+ approvalActor: provenance.approvalActor
1384
+ }
1385
+ }
1386
+ : { observedAdmission: { ...evidence, actor: "reconciler", observationId: provenance.observationId } };
1387
+ /** Whether a stored binding was admitted by exactly this provenance and absence evidence (an exact replay). */
1388
+ const sameAdmission = (request, expectedAbsenceToken, provenance) => {
1389
+ if (provenance._tag === "approved") {
1390
+ const stored = request.prospectiveAdmission;
1391
+ return stored !== undefined &&
1392
+ stored.expectedAbsenceToken === expectedAbsenceToken &&
1393
+ stored.approvalJobId === provenance.approvalJobId && stored.approvalActor === provenance.approvalActor;
1394
+ }
1395
+ const stored = request.observedAdmission;
1396
+ return stored !== undefined &&
1397
+ stored.expectedAbsenceToken === expectedAbsenceToken && stored.observationId === provenance.observationId;
1398
+ };
1189
1399
  export class WorkStore {
1190
1400
  #database;
1191
1401
  #cryptoService;
@@ -1289,6 +1499,13 @@ export class WorkStore {
1289
1499
  goal_id TEXT NOT NULL,
1290
1500
  record TEXT NOT NULL
1291
1501
  );
1502
+ -- One row per approved abandonment, committed with the goal checkpoint whose
1503
+ -- event id equals its approval job id, so work_goal_events bounds it too.
1504
+ CREATE TABLE IF NOT EXISTS work_goal_abandonments (
1505
+ approval_job_id TEXT PRIMARY KEY,
1506
+ goal_id TEXT NOT NULL,
1507
+ record TEXT NOT NULL
1508
+ );
1292
1509
  CREATE TABLE IF NOT EXISTS work_decision_handoffs (
1293
1510
  handoff_id TEXT PRIMARY KEY,
1294
1511
  session_id TEXT NOT NULL UNIQUE,
@@ -1312,9 +1529,32 @@ export class WorkStore {
1312
1529
  END;
1313
1530
  CREATE INDEX IF NOT EXISTS work_decision_handoffs_lane_time
1314
1531
  ON work_decision_handoffs (lane_id, occurred_at, handoff_id);
1315
- CREATE INDEX IF NOT EXISTS work_decision_handoffs_session
1532
+ CREATE UNIQUE INDEX IF NOT EXISTS work_decision_handoffs_session
1316
1533
  ON work_decision_handoffs (session_id);
1534
+ CREATE TABLE IF NOT EXISTS work_observed_facts (
1535
+ subject TEXT PRIMARY KEY,
1536
+ observation_id TEXT NOT NULL,
1537
+ observed_at INTEGER NOT NULL,
1538
+ confirmed_at INTEGER NOT NULL,
1539
+ record TEXT NOT NULL
1540
+ );
1541
+ CREATE INDEX IF NOT EXISTS work_observed_facts_age
1542
+ ON work_observed_facts (confirmed_at, subject);
1543
+ CREATE TABLE IF NOT EXISTS work_reconciler_events (
1544
+ event_id TEXT PRIMARY KEY,
1545
+ goal_id TEXT NOT NULL
1546
+ );
1547
+ CREATE TABLE IF NOT EXISTS work_observed_failures (
1548
+ subject TEXT PRIMARY KEY,
1549
+ source TEXT NOT NULL,
1550
+ reason TEXT NOT NULL,
1551
+ since INTEGER NOT NULL,
1552
+ last_at INTEGER NOT NULL
1553
+ );
1554
+ CREATE INDEX IF NOT EXISTS work_observed_failures_age
1555
+ ON work_observed_failures (last_at, subject);
1317
1556
  `);
1557
+ requireUniqueSessionIndex(database);
1318
1558
  const columns = Schema.decodeUnknownSync(Schema.Array(Schema.Struct({ name: Schema.String })))(database.prepare("PRAGMA table_info(work_goal_events)").all());
1319
1559
  if (!columns.some(({ name }) => name === "transaction_id")) {
1320
1560
  database.exec("ALTER TABLE work_goal_events ADD COLUMN transaction_id TEXT");
@@ -1876,6 +2116,155 @@ export class WorkStore {
1876
2116
  return yield* decision.error;
1877
2117
  return decision.result;
1878
2118
  });
2119
+ /**
2120
+ * Applies an approved `work.abandon` job: the goal, owned by exactly the
2121
+ * approved owner and still at the approved head, becomes `abandoned` with its
2122
+ * blocker cleared and a status activity naming the job. A goal with an
2123
+ * active lane, or already finished, is refused; nothing else is written. An
2124
+ * exact replay of the job returns the stored result.
2125
+ */
2126
+ abandon = Effect.fn("WorkStore.abandon")(function* (request) {
2127
+ const decoded = yield* Schema.decodeUnknownEffect(WorkGoalAbandonment)(request).pipe(Effect.mapError(storeError("abandon.decode")));
2128
+ const jobId = decoded.approvalJobId;
2129
+ const observedAt = yield* Clock.currentTimeMillis;
2130
+ yield* this.secureFiles();
2131
+ const decision = yield* Effect.try({
2132
+ try: () => {
2133
+ let transaction = false;
2134
+ const reject = (error) => {
2135
+ this.#database.exec("ROLLBACK");
2136
+ transaction = false;
2137
+ return { _tag: "rejected", error };
2138
+ };
2139
+ try {
2140
+ this.#database.exec("BEGIN IMMEDIATE");
2141
+ transaction = true;
2142
+ const priorRaw = this.#database
2143
+ .prepare(`SELECT approval_job_id AS approvalJobId, goal_id AS goalId, record
2144
+ FROM work_goal_abandonments WHERE approval_job_id = ?`)
2145
+ .get(jobId);
2146
+ if (priorRaw !== undefined) {
2147
+ const row = Schema.decodeUnknownSync(ReassignmentRow)(priorRaw);
2148
+ const prior = Schema.decodeUnknownSync(WorkGoalAbandoned)(JSON.parse(row.record));
2149
+ if (row.approvalJobId !== prior.abandonment.approvalJobId || row.goalId !== prior.abandonment.goalId) {
2150
+ return reject(new WorkStoreError({ cause: { prior, row }, operation: "abandon.replay.identity-mismatch" }));
2151
+ }
2152
+ if (!Equal.equals(prior.abandonment, decoded)) {
2153
+ return reject(new WorkGoalAbandonmentConflictError({ approvalJobId: jobId, reason: "payload_mismatch" }));
2154
+ }
2155
+ this.#database.exec("ROLLBACK");
2156
+ transaction = false;
2157
+ return { _tag: "abandoned", result: prior };
2158
+ }
2159
+ const collision = this.#database
2160
+ .prepare(`SELECT 1 FROM work_goal_events WHERE event_id = ?
2161
+ UNION ALL SELECT 1 FROM work_lane_operations WHERE operation_id = ?
2162
+ UNION ALL SELECT 1 FROM work_agent_bindings WHERE dispatch_request_id = ? LIMIT 1`)
2163
+ .get(jobId, jobId, jobId);
2164
+ if (collision !== undefined) {
2165
+ return reject(new WorkGoalAbandonmentConflictError({ approvalJobId: jobId, reason: "identifier_in_use" }));
2166
+ }
2167
+ const eventRows = Schema.decodeUnknownSync(Schema.Array(AgentBindingGoalEventRow))(this.#database
2168
+ .prepare(`SELECT event_id AS eventId, goal_id AS goalId, occurred_at AS occurredAt, record
2169
+ FROM work_goal_events ORDER BY occurred_at ASC, event_id ASC LIMIT ?`)
2170
+ .all(workHistoryMaxEvents + 1));
2171
+ const history = [];
2172
+ for (const row of eventRows) {
2173
+ const event = decodeAgentBindingGoalEvent(row, "abandon.history");
2174
+ if (event._tag === "invalid")
2175
+ return reject(event.error);
2176
+ history.push(event.checkpoint);
2177
+ }
2178
+ const head = history.filter(({ goal }) => goal.id === decoded.goalId).at(-1);
2179
+ if (head === undefined ||
2180
+ head.eventId !== decoded.expectedGoalEventId ||
2181
+ head.goal.updatedAt !== decoded.expectedGoalUpdatedAt) {
2182
+ return reject(new WorkGoalRevisionConflictError({
2183
+ goalId: decoded.goalId,
2184
+ expectedEventId: decoded.expectedGoalEventId,
2185
+ expectedUpdatedAt: decoded.expectedGoalUpdatedAt,
2186
+ actualEventId: head?.eventId ?? null,
2187
+ actualUpdatedAt: head?.goal.updatedAt ?? null
2188
+ }));
2189
+ }
2190
+ if (!Equal.equals(head.goal.owner, decoded.owner)) {
2191
+ return reject(new WorkGoalOwnerMismatchError({
2192
+ goalId: decoded.goalId,
2193
+ laneId: null,
2194
+ expectedOwner: { id: decoded.owner.id, name: decoded.owner.name },
2195
+ actualOwner: { id: head.goal.owner.id, name: head.goal.owner.name }
2196
+ }));
2197
+ }
2198
+ if (isTerminalWorkState(head.goal.state)) {
2199
+ return reject(new WorkGoalTerminalError({ goalId: decoded.goalId, state: head.goal.state }));
2200
+ }
2201
+ const ledger = readValidatedLaneLedger(this.#database, "abandon.lane");
2202
+ if (ledger._tag === "invalid")
2203
+ return reject(ledger.error);
2204
+ const active = ledger.entries.find(({ claim }) => claim.goalId === decoded.goalId && claim.phase !== "shipped");
2205
+ if (active !== undefined) {
2206
+ return reject(new WorkGoalLaneActiveError({ goalId: decoded.goalId, laneId: active.claim.laneId }));
2207
+ }
2208
+ // occurredAt equals updatedAt for every valid checkpoint; both bound the ordering defensively.
2209
+ const at = Math.max(observedAt, head.occurredAt + 1, head.goal.updatedAt + 1);
2210
+ const existingActivity = head.goal.activity ?? [];
2211
+ const goal = Schema.decodeUnknownSync(WorkGoal)({
2212
+ ...withoutBlockers(head.goal),
2213
+ state: "abandoned",
2214
+ // A full activity list keeps the owner's entries and takes no note; the
2215
+ // checkpoint's event id and the abandonments table still name the job.
2216
+ activity: existingActivity.length >= goalActivityLimit ? existingActivity : [
2217
+ ...existingActivity,
2218
+ {
2219
+ id: jobId,
2220
+ kind: "status",
2221
+ summary: workAbandonActivitySummary(decoded, jobId, decoded.approvalHash),
2222
+ occurredAt: at
2223
+ }
2224
+ ],
2225
+ updatedAt: at
2226
+ });
2227
+ const checkpoint = Schema.decodeUnknownSync(WorkGoalCheckpoint)({
2228
+ version: "herdr.work.event.v1",
2229
+ eventId: jobId,
2230
+ occurredAt: at,
2231
+ goal
2232
+ });
2233
+ const result = Schema.decodeUnknownSync(WorkGoalAbandoned)({ abandonment: decoded, checkpoint });
2234
+ const familyError = validateGoalFamilyHistory([...history, checkpoint]);
2235
+ if (familyError !== undefined)
2236
+ return reject(familyError);
2237
+ const capacity = agentBindingAdmissionError({
2238
+ history,
2239
+ candidate: checkpoint,
2240
+ operationCount: 0,
2241
+ operationBytes: 0,
2242
+ candidateOperationBytes: 0
2243
+ });
2244
+ if (capacity !== undefined)
2245
+ return reject(capacity);
2246
+ this.#database
2247
+ .prepare("INSERT INTO work_goal_events (event_id, goal_id, occurred_at, record) VALUES (?, ?, ?, ?)")
2248
+ .run(checkpoint.eventId, checkpoint.goal.id, checkpoint.occurredAt, JSON.stringify(checkpoint));
2249
+ this.#database
2250
+ .prepare("INSERT INTO work_goal_abandonments (approval_job_id, goal_id, record) VALUES (?, ?, ?)")
2251
+ .run(jobId, decoded.goalId, JSON.stringify(result));
2252
+ this.#database.exec("COMMIT");
2253
+ transaction = false;
2254
+ return { _tag: "abandoned", result };
2255
+ }
2256
+ catch (cause) {
2257
+ if (transaction)
2258
+ this.#database.exec("ROLLBACK");
2259
+ throw cause;
2260
+ }
2261
+ },
2262
+ catch: storeError("abandon.transaction")
2263
+ });
2264
+ if (decision._tag === "rejected")
2265
+ return yield* decision.error;
2266
+ return decision.result;
2267
+ });
1879
2268
  admissionPreflight = Effect.fn("WorkStore.admissionPreflight")(function* (target) {
1880
2269
  const decoded = yield* Schema.decodeUnknownEffect(WorkAdmissionTarget)(target).pipe(Effect.mapError(storeError("admission.preflight.decode")));
1881
2270
  const state = yield* Effect.try({
@@ -1888,8 +2277,27 @@ export class WorkStore {
1888
2277
  const preflight = { _tag: "prospective", target: state.target, absenceToken };
1889
2278
  return preflight;
1890
2279
  });
2280
+ /**
2281
+ * Admits an existing owner after an approved Fleet job. Its absence token is
2282
+ * re-checked inside the write; an exact replay returns the stored link.
2283
+ */
1891
2284
  admitExistingOwner = Effect.fn("WorkStore.admitExistingOwner")(function* (request) {
1892
2285
  const decoded = yield* Schema.decodeUnknownEffect(WorkProspectiveAdmission)(request).pipe(Effect.mapError(storeError("admission.decode")));
2286
+ return yield* this.#admit(decoded, {
2287
+ _tag: "approved",
2288
+ approvalActor: decoded.approvalActor,
2289
+ approvalJobId: decoded.approvalJobId
2290
+ });
2291
+ });
2292
+ /**
2293
+ * Admits a worker the reconciler observed, without an approval: the same
2294
+ * write and checks as `admitExistingOwner`, credited to the observation.
2295
+ */
2296
+ admitObserved = Effect.fn("WorkStore.admitObserved")(function* (request) {
2297
+ const decoded = yield* Schema.decodeUnknownEffect(WorkObservedAdmission)(request).pipe(Effect.mapError(storeError("admission.decode")));
2298
+ return yield* this.#admit(decoded, { _tag: "observed", observationId: decoded.observationId });
2299
+ });
2300
+ #admit = Effect.fn("WorkStore.admit")(function* (decoded, provenance) {
1893
2301
  const target = Schema.decodeUnknownSync(WorkAdmissionTarget)(decoded);
1894
2302
  const now = yield* Clock.currentTimeMillis;
1895
2303
  yield* this.secureFiles();
@@ -1913,13 +2321,10 @@ export class WorkStore {
1913
2321
  const state = admissionState(this.#database, target);
1914
2322
  if (state._tag === "existing") {
1915
2323
  const existing = state.link;
1916
- const provenance = existing.binding.request.prospectiveAdmission;
1917
2324
  this.#database.exec("ROLLBACK");
1918
2325
  transaction = false;
1919
2326
  return existing.binding.request.dispatchRequestId === decoded.operationId &&
1920
- provenance?.expectedAbsenceToken === decoded.expectedAbsenceToken &&
1921
- provenance?.approvalJobId === decoded.approvalJobId &&
1922
- provenance.approvalActor === decoded.approvalActor &&
2327
+ sameAdmission(existing.binding.request, decoded.expectedAbsenceToken, provenance) &&
1923
2328
  existing.goal.title === decoded.title &&
1924
2329
  existing.goal.summary === decoded.summary &&
1925
2330
  existing.goal.detail === decoded.detail
@@ -1962,7 +2367,7 @@ export class WorkStore {
1962
2367
  activity: [{
1963
2368
  id: `${decoded.operationId}.admission`,
1964
2369
  kind: "status",
1965
- summary: `Prospective admission of an existing owner by approved Fleet job ${decoded.approvalJobId}`,
2370
+ summary: admissionSummary(provenance),
1966
2371
  occurredAt: createdAt
1967
2372
  }],
1968
2373
  review: { state: "requested", summary: null, updatedAt: createdAt, url: decoded.reviewUrl },
@@ -1996,14 +2401,12 @@ export class WorkStore {
1996
2401
  laneId: decoded.laneId,
1997
2402
  expectedRevision: 0,
1998
2403
  worker: decoded.worker,
1999
- prospectiveAdmission: {
2404
+ ...admissionRecord(provenance, {
2000
2405
  sessionId: decoded.sessionId,
2001
2406
  workAssignment: decoded.expectedWork,
2002
2407
  baseHead: decoded.baseHead,
2003
- expectedAbsenceToken: decoded.expectedAbsenceToken,
2004
- approvalJobId: decoded.approvalJobId,
2005
- approvalActor: decoded.approvalActor
2006
- }
2408
+ expectedAbsenceToken: decoded.expectedAbsenceToken
2409
+ })
2007
2410
  },
2008
2411
  lane,
2009
2412
  checkpoint: {
@@ -2050,7 +2453,7 @@ export class WorkStore {
2050
2453
  _tag: "rejected",
2051
2454
  error: capacity ?? new WorkProjectionError({
2052
2455
  cause: decoded,
2053
- detail: "prospective admission exceeds Work capacity",
2456
+ detail: "admission exceeds Work capacity",
2054
2457
  reason: "capacity_exceeded"
2055
2458
  })
2056
2459
  };
@@ -2197,7 +2600,7 @@ export class WorkStore {
2197
2600
  if (currentDecision._tag === "invalid")
2198
2601
  return reject(currentDecision.error);
2199
2602
  const current = currentDecision.checkpoint;
2200
- if (current.goal.state === "completed") {
2603
+ if (current.goal.state === "completed" || current.goal.state === "abandoned") {
2201
2604
  return reject(new WorkAgentBindingAuthorityError({
2202
2605
  actualRevision: lane.revision,
2203
2606
  expectedRevision: decoded.expectedRevision,
@@ -2334,8 +2737,26 @@ export class WorkStore {
2334
2737
  const entries = yield* Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ dispatchRequestId: WorkAgentBindingRequest.fields.dispatchRequestId })))(rows).pipe(Effect.mapError(storeError("agent-binding.lane.rows")));
2335
2738
  return entries.map(({ dispatchRequestId }) => dispatchRequestId);
2336
2739
  });
2337
- append = Effect.fn("WorkStore.append")(function* (event) {
2740
+ append = (event) => this.appendAt(event, null).pipe(
2741
+ // Only an expected head can raise it; a plain append never checks one.
2742
+ Effect.catchTag("WorkGoalRevisionConflictError", Effect.die));
2743
+ /**
2744
+ * Appends one checkpoint. With `expectedHead`, the goal's latest checkpoint
2745
+ * must still be that one when the write commits (an exact replay always
2746
+ * succeeds), so a writer that planned from the head cannot bury a newer
2747
+ * owner checkpoint.
2748
+ */
2749
+ appendAt = Effect.fn("WorkStore.append")(function* (event, guard) {
2338
2750
  const decoded = yield* Schema.decodeUnknownEffect(WorkGoalCheckpoint)(event).pipe(Effect.mapError(storeError("append.decode")));
2751
+ // The reconciler's event and activity ids are its provenance; no other
2752
+ // writer may use them.
2753
+ if (guard === null && decoded.eventId.startsWith(reconcilerEventPrefix)) {
2754
+ return yield* new WorkProjectionError({
2755
+ cause: decoded.eventId,
2756
+ detail: `event ids starting with ${reconcilerEventPrefix} are reserved for the reconciler`,
2757
+ reason: "malformed"
2758
+ });
2759
+ }
2339
2760
  yield* this.secureFiles();
2340
2761
  const decision = yield* Effect.try({
2341
2762
  try: () => {
@@ -2352,12 +2773,37 @@ export class WorkStore {
2352
2773
  .prepare(`SELECT record FROM work_goal_events
2353
2774
  WHERE event_id = ? OR (goal_id = ? AND occurred_at = ?)`)
2354
2775
  .all(decoded.eventId, decoded.goal.id, decoded.occurredAt)).map(({ record }) => Schema.decodeUnknownSync(WorkGoalCheckpoint)(JSON.parse(record)));
2355
- if (collisions.length > 0) {
2356
- if (collisions.every((existing) => Equal.equals(existing, decoded))) {
2357
- this.#database.exec("ROLLBACK");
2358
- transaction = false;
2359
- return { _tag: "replayed", event: decoded };
2776
+ if (collisions.length > 0 && collisions.every((existing) => Equal.equals(existing, decoded))) {
2777
+ this.#database.exec("ROLLBACK");
2778
+ transaction = false;
2779
+ return { _tag: "replayed", event: decoded };
2780
+ }
2781
+ if (guard !== null) {
2782
+ // Planned from this head and this fact: if either moved, the plan is stale.
2783
+ const headRow = this.#database.prepare(`SELECT record FROM work_goal_events WHERE goal_id = ?
2784
+ ORDER BY occurred_at DESC, event_id DESC LIMIT 1`).get(decoded.goal.id);
2785
+ const head = headRow === undefined ? null : Schema.decodeUnknownSync(WorkGoalCheckpoint)(JSON.parse(Schema.decodeUnknownSync(StoredEventRow)(headRow).record));
2786
+ const factRow = this.#database.prepare("SELECT observation_id AS observationId FROM work_observed_facts WHERE subject = ?").get(guard.fact.subject);
2787
+ const fact = factRow === undefined ? null : Schema.decodeUnknownSync(ObservationIdRow)(factRow);
2788
+ if (head?.eventId !== guard.head.eventId || fact?.observationId !== guard.fact.observationId) {
2789
+ return reject(new WorkGoalRevisionConflictError({
2790
+ actualEventId: head?.eventId ?? null,
2791
+ actualUpdatedAt: head?.goal.updatedAt ?? null,
2792
+ expectedEventId: guard.head.eventId,
2793
+ expectedUpdatedAt: guard.head.goal.updatedAt,
2794
+ goalId: decoded.goal.id
2795
+ }));
2796
+ }
2797
+ const count = Schema.decodeUnknownSync(CountRow)(this.#database.prepare("SELECT COUNT(*) AS count FROM work_goal_events").get()).count;
2798
+ if (count + 1 > workHistoryMaxEvents - guard.reserve) {
2799
+ return reject(new WorkProjectionError({
2800
+ cause: count,
2801
+ detail: `the reconciler leaves ${guard.reserve} of ${workHistoryMaxEvents} checkpoints free`,
2802
+ reason: "capacity_exceeded"
2803
+ }));
2360
2804
  }
2805
+ }
2806
+ if (collisions.length > 0) {
2361
2807
  return reject(new WorkCheckpointConflictError({
2362
2808
  eventId: decoded.eventId,
2363
2809
  goalId: decoded.goal.id,
@@ -2412,6 +2858,11 @@ export class WorkStore {
2412
2858
  }
2413
2859
  }
2414
2860
  const result = this.#database.prepare("INSERT INTO work_goal_events (event_id, goal_id, occurred_at, record) VALUES (?, ?, ?, ?)").run(decoded.eventId, decoded.goal.id, decoded.occurredAt, JSON.stringify(decoded));
2861
+ // The reconciler's own record of what it wrote: its authorship and its
2862
+ // once-per-goal rule come from this table, never from an id's spelling.
2863
+ if (guard !== null) {
2864
+ this.#database.prepare("INSERT INTO work_reconciler_events (event_id, goal_id) VALUES (?, ?)").run(decoded.eventId, decoded.goal.id);
2865
+ }
2415
2866
  this.#database.exec("COMMIT");
2416
2867
  transaction = false;
2417
2868
  return { _tag: "inserted", changes: result.changes };
@@ -2443,6 +2894,14 @@ export class WorkStore {
2443
2894
  }
2444
2895
  const transaction = yield* Schema.decodeUnknownEffect(TransactionId)(transactionId).pipe(Effect.mapError(storeError("appendMany.decode.transaction")));
2445
2896
  const decoded = yield* Effect.forEach(events, (event) => Schema.decodeUnknownEffect(WorkGoalCheckpoint)(event).pipe(Effect.mapError(storeError("appendMany.decode.event"))));
2897
+ const reserved = decoded.find(({ eventId }) => eventId.startsWith(reconcilerEventPrefix));
2898
+ if (reserved !== undefined) {
2899
+ return yield* new WorkProjectionError({
2900
+ cause: reserved.eventId,
2901
+ detail: `event ids starting with ${reconcilerEventPrefix} are reserved for the reconciler`,
2902
+ reason: "malformed"
2903
+ });
2904
+ }
2446
2905
  if (decoded.length === 0) {
2447
2906
  return yield* new WorkProjectionError({
2448
2907
  cause: events,
@@ -2944,7 +3403,7 @@ export class WorkStore {
2944
3403
  goal.goalFamily?.role !== "canonical" ||
2945
3404
  goal.goalFamily.canonicalGoalId !== goal.id)
2946
3405
  return rejectLink("missing_provenance");
2947
- if (goal.state === "completed" || goal.state === "deployed")
3406
+ if (isTerminalWorkState(goal.state))
2948
3407
  return rejectLink("terminal_goal");
2949
3408
  if (!Equal.equals(goal.owner, reconciliation.expectedOwner))
2950
3409
  return rejectLink("owner_mismatch");
@@ -3360,9 +3819,18 @@ export class WorkStore {
3360
3819
  phase, revision, record
3361
3820
  FROM work_lane_operations ORDER BY operation_id ASC
3362
3821
  LIMIT ?`).all(workLaneOperationMaxRecords + 1);
3822
+ const facts = this.#database.prepare(`SELECT subject, observation_id AS observationId, observed_at AS observedAt,
3823
+ confirmed_at AS confirmedAt, record
3824
+ FROM work_observed_facts ORDER BY subject ASC
3825
+ LIMIT ?`).all(workObservedFactMaxRecords + 1);
3826
+ const failures = this.#database.prepare(`SELECT subject, source, reason, since, last_at AS lastAt
3827
+ FROM work_observed_failures ORDER BY subject ASC LIMIT ?`).all(workObservedFactMaxRecords + 1);
3828
+ const reconcilerRows = this.#database.prepare("SELECT event_id AS eventId, goal_id AS goalId FROM work_reconciler_events ORDER BY event_id ASC LIMIT ?").all(workHistoryMaxEvents + 1);
3829
+ const reassignments = this.#database.prepare(`SELECT approval_job_id AS approvalJobId, goal_id AS goalId FROM work_goal_reassignments
3830
+ UNION ALL SELECT approval_job_id AS approvalJobId, goal_id AS goalId FROM work_goal_abandonments`).all();
3363
3831
  this.#database.exec("COMMIT");
3364
3832
  inTransaction = false;
3365
- return { bindings, events, laneOperations };
3833
+ return { bindings, events, facts, failures, laneOperations, reassignments, reconcilerRows };
3366
3834
  }
3367
3835
  catch (cause) {
3368
3836
  if (inTransaction)
@@ -3387,6 +3855,23 @@ export class WorkStore {
3387
3855
  const eventById = new Map(eventRows.map((row) => [row.eventId, row]));
3388
3856
  const laneByOperation = new Map(laneRows.map((row) => [row.operationId, row]));
3389
3857
  let logicalObservedAt = null;
3858
+ // An approved job's activity id is its operation (recovery), the operation
3859
+ // plus `.admission` (prospective admission), or the job id (reassignment).
3860
+ const reassignmentRows = yield* Schema.decodeUnknownEffect(Schema.Array(ReassignmentApprovalRow))(source.reassignments).pipe(Effect.mapError(storeError("snapshot-input.decode-reassignments")));
3861
+ // An approved reassignment or abandonment may be stamped past the clock
3862
+ // (one past a future-dated head); like a binding, it moves logical time so
3863
+ // a default snapshot shows what the approved job wrote.
3864
+ for (const { approvalJobId } of reassignmentRows) {
3865
+ const written = eventById.get(approvalJobId);
3866
+ if (written !== undefined)
3867
+ logicalObservedAt = Math.max(logicalObservedAt ?? 0, written.occurredAt);
3868
+ }
3869
+ const approvals = reassignmentRows.map(({ approvalJobId, goalId }) => ({
3870
+ activityId: approvalJobId,
3871
+ approvalJobId,
3872
+ goalId
3873
+ }));
3874
+ const observedAdmissions = [];
3390
3875
  for (const row of bindingRows) {
3391
3876
  const binding = yield* Effect.try({
3392
3877
  try: () => Schema.decodeUnknownSync(WorkAgentBinding)(JSON.parse(row.record)),
@@ -3407,8 +3892,173 @@ export class WorkStore {
3407
3892
  if (readbackError !== undefined)
3408
3893
  return yield* readbackError;
3409
3894
  logicalObservedAt = Math.max(logicalObservedAt ?? 0, binding.checkpoint.occurredAt);
3895
+ const goalId = binding.checkpoint.goal.id;
3896
+ const { dispatchRequestId, existingGoalRecovery, observedAdmission, ownerReassignment, prospectiveAdmission } = binding.request;
3897
+ if (observedAdmission !== undefined) {
3898
+ observedAdmissions.push({ eventId: `${dispatchRequestId}.admission`, goalId });
3899
+ }
3900
+ if (prospectiveAdmission !== undefined) {
3901
+ approvals.push({
3902
+ activityId: `${dispatchRequestId}.admission`,
3903
+ approvalJobId: prospectiveAdmission.approvalJobId,
3904
+ goalId
3905
+ });
3906
+ }
3907
+ if (existingGoalRecovery !== undefined) {
3908
+ approvals.push({ activityId: dispatchRequestId, approvalJobId: existingGoalRecovery.approvalJobId, goalId });
3909
+ }
3910
+ if (ownerReassignment !== undefined) {
3911
+ approvals.push({
3912
+ activityId: ownerReassignment.approvalJobId,
3913
+ approvalJobId: ownerReassignment.approvalJobId,
3914
+ goalId
3915
+ });
3916
+ }
3917
+ }
3918
+ const factRows = yield* Schema.decodeUnknownEffect(Schema.Array(ObservedFactRow))(source.facts).pipe(Effect.mapError(storeError("snapshot-input.decode-fact-rows")));
3919
+ const failures = yield* Schema.decodeUnknownEffect(Schema.Array(WorkObservedFailure))(source.failures).pipe(Effect.mapError(storeError("snapshot-input.decode-failures")));
3920
+ if (factRows.length > workObservedFactMaxRecords || failures.length > workObservedFactMaxRecords) {
3921
+ return yield* new WorkStoreError({ cause: factRows.length, operation: "snapshot-input.fact-capacity" });
3922
+ }
3923
+ const facts = yield* Effect.forEach(factRows, (row) => Effect.try({
3924
+ try: () => Schema.decodeUnknownSync(WorkObservedFact)({
3925
+ confirmedAt: row.confirmedAt,
3926
+ observation: JSON.parse(row.record),
3927
+ observationId: row.observationId,
3928
+ observedAt: row.observedAt,
3929
+ subject: row.subject
3930
+ }),
3931
+ catch: storeError("snapshot-input.decode-fact")
3932
+ }));
3933
+ // A row is filed under its own facts' subject; anything else is a corrupt row.
3934
+ const misfiled = facts.find((fact) => fact.subject !== observationSubject(fact.observation));
3935
+ if (misfiled !== undefined) {
3936
+ return yield* new WorkStoreError({ cause: misfiled.subject, operation: "snapshot-input.fact-subject" });
3937
+ }
3938
+ const reconcilerEvents = yield* Schema.decodeUnknownEffect(Schema.Array(ReconcilerEventRow))(source.reconcilerRows)
3939
+ .pipe(Effect.mapError(storeError("snapshot-input.decode-reconciler-events")));
3940
+ // Each current activity's origin: the checkpoint that started its current
3941
+ // unchanged run. An activity that disappears, or reads differently, starts
3942
+ // a new run, so a later owner rewrite or re-creation is never credited to
3943
+ // whoever wrote an earlier version.
3944
+ const activityOrigins = new Map();
3945
+ const previousByGoal = new Map();
3946
+ const ordered = events.toSorted((left, right) => left.occurredAt - right.occurredAt || (left.eventId < right.eventId ? -1 : left.eventId > right.eventId ? 1 : 0));
3947
+ for (const event of ordered) {
3948
+ const previous = previousByGoal.get(event.goal.id) ?? new Map();
3949
+ const current = new Map((event.goal.activity ?? []).map((activity) => [activity.id, activity]));
3950
+ for (const [id, activity] of current) {
3951
+ const before = previous.get(id);
3952
+ const unchanged = before !== undefined && before.kind === activity.kind &&
3953
+ before.summary === activity.summary &&
3954
+ before.occurredAt === activity.occurredAt;
3955
+ if (!unchanged)
3956
+ activityOrigins.set(`${event.goal.id}\u0000${id}`, { activity, eventId: event.eventId });
3957
+ }
3958
+ previousByGoal.set(event.goal.id, current);
3959
+ }
3960
+ return {
3961
+ activityOrigins,
3962
+ approvals,
3963
+ events,
3964
+ facts,
3965
+ failures,
3966
+ logicalObservedAt,
3967
+ observedAdmissions,
3968
+ reconcilerEvents
3969
+ };
3970
+ });
3971
+ reconcile = Effect.fn("WorkStore.reconcile")(function* () {
3972
+ const source = yield* this.snapshotInput();
3973
+ if (workHistoryMaxEvents - source.events.length < workReconcilerHeadroom) {
3974
+ return yield* new WorkProjectionError({
3975
+ cause: source.events.length,
3976
+ detail: `the reconciler leaves ${workReconcilerHeadroom} of ${workHistoryMaxEvents} checkpoints free`,
3977
+ reason: "capacity_exceeded"
3978
+ });
3410
3979
  }
3411
- return { events, logicalObservedAt };
3980
+ // A goal is stamped by the reconciler at most once, whatever its facts
3981
+ // look like later: a reopened goal stays the owner's.
3982
+ const stamped = new Map(source.reconcilerEvents.map(({ eventId, goalId }) => [goalId, eventId]));
3983
+ const cryptoService = this.#cryptoService;
3984
+ const now = yield* Clock.currentTimeMillis;
3985
+ const appendAt = (checkpoint, guard) => this.appendAt(checkpoint, guard);
3986
+ return yield* Effect.forEach(terminalCandidates(source.events, source.facts), Effect.fnUntraced(function* (candidate) {
3987
+ const goalId = candidate.head.goal.id;
3988
+ const previous = stamped.get(goalId);
3989
+ if (previous !== undefined)
3990
+ return { _tag: "recorded", eventId: previous, goalId };
3991
+ const digest = yield* cryptoService.digest("SHA-256", utf8.encode(`${candidate.fact.observationId}\u0000${goalId}`)).pipe(Effect.mapError(storeError("reconcile.digest")));
3992
+ const eventId = `${reconcilerEventPrefix}${Hex.encode(digest)}`;
3993
+ const checkpoint = terminalCheckpoint(candidate, eventId, now);
3994
+ // A goal that cannot take its terminal checkpoint (an activity already
3995
+ // holds the reconciler's id, a timestamp past the bound) is left for
3996
+ // its owner: reported as a conflict, never failing the other goals.
3997
+ if (Result.isFailure(checkpoint))
3998
+ return { _tag: "conflict", goalId };
3999
+ return yield* appendAt(checkpoint.success, {
4000
+ fact: { observationId: candidate.fact.observationId, subject: candidate.fact.subject },
4001
+ head: candidate.head,
4002
+ reserve: workReconcilerHeadroom
4003
+ }).pipe(Effect.as({
4004
+ _tag: "applied",
4005
+ eventId,
4006
+ goalId,
4007
+ state: candidate.pullRequest.state === "merged" ? "completed" : "abandoned"
4008
+ }), Effect.catchTag("WorkGoalRevisionConflictError", () => Effect.succeed({ _tag: "conflict", goalId })));
4009
+ }));
4010
+ });
4011
+ observe = Effect.fn("WorkStore.observe")(function* (envelopes) {
4012
+ const decoded = yield* Schema.decodeUnknownEffect(Schema.Array(WorkObservationEnvelope).check(Schema.isMaxLength(workObservedFactMaxRecords)))(envelopes).pipe(Effect.mapError(storeError("observe.decode")));
4013
+ const cryptoService = this.#cryptoService;
4014
+ // An observation from further ahead than clock skew allows would outrank
4015
+ // every real reading until wall time caught up, so it is skipped as stale.
4016
+ const clockNow = yield* Clock.currentTimeMillis;
4017
+ const latestAllowed = clockNow + workObservationMaxSkewMillis;
4018
+ const prepared = yield* Effect.forEach(decoded, Effect.fnUntraced(function* (envelope) {
4019
+ // Composite subjects are bounded on their own; check before anything is written.
4020
+ const subject = yield* Schema.decodeUnknownEffect(WorkObservationSubject)(observationSubject(envelope.observation)).pipe(Effect.mapError(storeError("observe.subject")));
4021
+ if (envelope.observedAt > latestAllowed)
4022
+ return { _tag: "future", subject };
4023
+ if (envelope.observation._tag === "unknown") {
4024
+ if (canonicalSubject(envelope.observation.source, envelope.observation.subject) === null) {
4025
+ return yield* new WorkStoreError({ cause: envelope.observation, operation: "observe.subject" });
4026
+ }
4027
+ return {
4028
+ _tag: "unknown",
4029
+ // Within the skew allowance, a reading from slightly ahead counts
4030
+ // as now, so it is visible to a snapshot taken now.
4031
+ observedAt: Math.min(envelope.observedAt, clockNow),
4032
+ reason: envelope.observation.reason,
4033
+ source: envelope.observation.source,
4034
+ subject
4035
+ };
4036
+ }
4037
+ // Case-insensitive identities are lowercased so a spelling change is the
4038
+ // same fact; encoding emits keys in schema field order, so equal facts
4039
+ // give equal text and one id.
4040
+ const record = JSON.stringify(Schema.encodeSync(WorkObservationEnvelope)({
4041
+ ...envelope,
4042
+ observation: canonicalObservation(envelope.observation)
4043
+ })
4044
+ .observation);
4045
+ const digest = yield* cryptoService.digest("SHA-256", utf8.encode(record)).pipe(Effect.mapError(storeError("observe.digest")));
4046
+ return {
4047
+ _tag: "fact",
4048
+ observationId: Hex.encode(digest),
4049
+ observedAt: Math.min(envelope.observedAt, clockNow),
4050
+ record,
4051
+ subject
4052
+ };
4053
+ }));
4054
+ yield* this.secureFiles();
4055
+ const report = yield* Effect.try({
4056
+ try: () => writeObservations(this.#database, prepared),
4057
+ catch: storeError("observe.write")
4058
+ });
4059
+ // The write may have created the WAL siblings; they get the same private mode.
4060
+ yield* this.secureFiles();
4061
+ return report;
3412
4062
  });
3413
4063
  secureFiles() {
3414
4064
  return this.#secureFiles;