@north-light/crouter 0.3.203 → 0.3.204

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.
@@ -7,11 +7,14 @@ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'no
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { createApiServer } from '../../daemon/api/server.js';
10
- import { cancelHumanTicket, deliverTerminalResult, resolvePageTicket } from '../../daemon/human/finish.js';
10
+ import { cancelHumanTicket, deliverTerminalResult, resolvePageTicket, settleFeedbackCompanion } from '../../daemon/human/finish.js';
11
11
  import { humanSend } from '../../commands/human/prompts.js';
12
12
  import { getNode, listNodes, closeDb } from '../canvas/index.js';
13
13
  import { readInboxSince } from '../feed/inbox.js';
14
+ import { createPageFeedbackComment } from '../human/feedback.js';
14
15
  import { resolveRef } from '../canvas/history.js';
16
+ import { transition } from '../runtime/lifecycle.js';
17
+ import { reviveNode } from '../runtime/revive.js';
15
18
  import { apiSocketPath } from '../canvas/paths.js';
16
19
  import { spawnNode } from '../runtime/nodes.js';
17
20
  import { bindTicketReplyRoute } from '../human/convention.js';
@@ -142,6 +145,36 @@ describe('terminal ticket delivery', () => {
142
145
  const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
143
146
  assert.equal(finals.length, 1, 'replay after a terminal bridge pushes nothing further');
144
147
  });
148
+ test('approved companions receive one canonical final while dismissed companions remain canceled', async () => {
149
+ const { bridge } = bridgeUnder();
150
+ const dir = submitBridgePage(bridge);
151
+ const companion = spawnNode({ kind: 'general', parent: bridge, cwd: home, name: 'feedback companion', lifecycle: 'resident' }).node_id;
152
+ createPageFeedbackComment(dir, { quote: 'Ship it?', note: 'Confirm the release.', companionNodeId: companion });
153
+ transition(companion, 'finish');
154
+ await resolvePageTicket(bridge, { question: { text: 'yes', edited: true } });
155
+ const finalized = getNode(companion);
156
+ assert.equal(finalized.status, 'done');
157
+ assert.equal(finalized.intent, 'done');
158
+ assert.ok(finalized.final_report);
159
+ assert.ok(finalized.finalized_at);
160
+ const finalReport = finalized.final_report;
161
+ const finalizedAt = finalized.finalized_at;
162
+ await deliverTerminalResult(bridge);
163
+ assert.equal(getNode(companion)?.final_report, finalReport, 'terminal replay preserves the first canonical final');
164
+ assert.equal(getNode(companion)?.finalized_at, finalizedAt, 'terminal replay preserves the first finalization time');
165
+ assert.throws(() => reviveNode(companion, { resume: true }), /finalization latch/);
166
+ const { bridge: dismissedBridge } = bridgeUnder();
167
+ const dismissedDir = submitBridgePage(dismissedBridge);
168
+ const dismissedCompanion = spawnNode({ kind: 'general', parent: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' }).node_id;
169
+ createPageFeedbackComment(dismissedDir, { quote: 'Ship it?', note: 'Do not ship.', companionNodeId: dismissedCompanion });
170
+ await cancelHumanTicket(dismissedBridge, { reason: 'dismissed', actor: 'human' });
171
+ assert.equal(getNode(dismissedCompanion)?.status, 'canceled');
172
+ assert.equal(getNode(dismissedCompanion)?.final_report, null);
173
+ const dismissedResult = readTicketResult(dismissedDir);
174
+ await settleFeedbackCompanion(dismissedBridge, dismissedResult.kind === 'canceled' ? 'canceled' : 'approved');
175
+ assert.equal(getNode(dismissedCompanion)?.status, 'canceled');
176
+ assert.equal(getNode(dismissedCompanion)?.final_report, null);
177
+ });
145
178
  test('cancelHumanTicket finishes the bridge and notifies non-actor subscribers', async () => {
146
179
  const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
147
180
  const bridge = spawnNode({ kind: 'human', parent: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
@@ -1,2 +1,2 @@
1
- /** Retire a companion only through a lifecycle transition legal for its current status. */
2
- export declare function retireCompanion(nodeId: string, outcome: 'approved' | 'canceled'): 'retired' | 'already' | 'missing';
1
+ /** Retire a companion only through the terminal transition legal for its current status. */
2
+ export declare function retireCompanion(nodeId: string, outcome: 'approved' | 'canceled'): Promise<'retired' | 'already' | 'missing'>;
@@ -3,25 +3,32 @@
3
3
  // can retire a feedback companion without importing the review finisher (which
4
4
  // itself imports human/finish.ts).
5
5
  import { getNode } from '../core/canvas/canvas.js';
6
+ import { FinalizationError, pushFinal } from '../core/feed/feed.js';
6
7
  import { transition } from '../core/runtime/lifecycle.js';
7
8
  import { headlessBrokerHost } from '../core/runtime/host.js';
8
- /** Retire a companion only through a lifecycle transition legal for its current status. */
9
- export function retireCompanion(nodeId, outcome) {
9
+ /** Retire a companion only through the terminal transition legal for its current status. */
10
+ export async function retireCompanion(nodeId, outcome) {
10
11
  const node = getNode(nodeId);
11
12
  if (node === null)
12
13
  return 'missing';
13
- if (node.status === 'done' || node.status === 'canceled')
14
+ if (node.status === 'canceled' || (outcome === 'canceled' && node.status === 'done'))
14
15
  return 'already';
15
- if (outcome === 'approved' && (node.status === 'active' || node.status === 'idle')) {
16
- transition(nodeId, 'finish');
16
+ if (outcome === 'approved' && (node.status === 'active' || node.status === 'idle' || node.status === 'done')) {
17
+ let result = 'retired';
18
+ try {
19
+ await pushFinal(nodeId, 'The companion conversation is complete.');
20
+ }
21
+ catch (error) {
22
+ if (!(error instanceof FinalizationError) || error.code !== 'already_finalized')
23
+ throw error;
24
+ result = 'already';
25
+ }
17
26
  try {
18
27
  headlessBrokerHost.teardown(nodeId);
19
28
  }
20
- catch { /* the terminal transition is authoritative */ }
21
- return 'retired';
29
+ catch { /* the canonical final is authoritative */ }
30
+ return result;
22
31
  }
23
- // Cancellation is legal from every status. An already-dead companion cannot
24
- // be finished, so it is retired as canceled even when approval won.
25
32
  transition(nodeId, 'cancel');
26
33
  if (node.status === 'active' || node.status === 'idle') {
27
34
  try {
@@ -1,10 +1,10 @@
1
1
  import type { TicketResult } from '../../core/human/types.js';
2
2
  /** Every ticket-ending action retires the ticket's feedback companion — done
3
- * when active or idle, never left resident. Idempotent and replay-safe; a
3
+ * with a canonical final when approved, never left resident. Idempotent and replay-safe; a
4
4
  * corrupt feedback file must not block result publication. Exported for the
5
5
  * daemon-start sweep, which must settle terminal tickets whether or not a
6
6
  * reply bridge exists. */
7
- export declare function settleFeedbackCompanion(ticketId: string): void;
7
+ export declare function settleFeedbackCompanion(ticketId: string, outcome: 'approved' | 'canceled'): Promise<void>;
8
8
  /**
9
9
  * The one completion path for a page ticket: claim takeover, exclusive result
10
10
  * publication, then optional reply delivery. Every addressing surface routes
@@ -14,15 +14,15 @@ import { transition } from '../../core/runtime/lifecycle.js';
14
14
  import { emitEvent } from '../../core/events/emit.js';
15
15
  import { retireCompanion } from '../companion-retire.js';
16
16
  /** Every ticket-ending action retires the ticket's feedback companion — done
17
- * when active or idle, never left resident. Idempotent and replay-safe; a
17
+ * with a canonical final when approved, never left resident. Idempotent and replay-safe; a
18
18
  * corrupt feedback file must not block result publication. Exported for the
19
19
  * daemon-start sweep, which must settle terminal tickets whether or not a
20
20
  * reply bridge exists. */
21
- export function settleFeedbackCompanion(ticketId) {
21
+ export async function settleFeedbackCompanion(ticketId, outcome) {
22
22
  try {
23
23
  const companionNodeId = readPageFeedback(ticketDir(ticketId)).companionNodeId;
24
24
  if (companionNodeId !== undefined)
25
- retireCompanion(companionNodeId, 'approved');
25
+ await retireCompanion(companionNodeId, outcome);
26
26
  }
27
27
  catch (error) {
28
28
  emitEvent({ level: 'warn', event: 'human.feedback_companion.retire_failed', error });
@@ -155,7 +155,7 @@ export async function deliverTerminalResult(ticketId) {
155
155
  // through here, and an inbox-only page with no reply bridge still settles
156
156
  // its companion. The daemon-start sweep settles the tickets that die before
157
157
  // reaching this call.
158
- settleFeedbackCompanion(ticketId);
158
+ await settleFeedbackCompanion(ticketId, result.kind === 'canceled' ? 'canceled' : 'approved');
159
159
  const replyRoute = readTicketReplyRoute(dir);
160
160
  if (replyRoute === null)
161
161
  return false;
@@ -21,7 +21,8 @@ export async function reconcileUndeliveredTickets() {
21
21
  if (!statSync(dir).isDirectory())
22
22
  continue;
23
23
  scanned += 1;
24
- if (readTicketResult(dir) === null)
24
+ const ticketResult = readTicketResult(dir);
25
+ if (ticketResult === null)
25
26
  continue;
26
27
  // Canonical reviews own terminal delivery and bridge retirement. Their
27
28
  // finisher retries only when its winning operation is explicitly repeated.
@@ -30,7 +31,7 @@ export async function reconcileUndeliveredTickets() {
30
31
  // A terminal page ticket retires its feedback companion whether or not a
31
32
  // reply bridge exists: inbox-only pages have no bridge row, yet the
32
33
  // daemon can die between result publication and companion retirement.
33
- settleFeedbackCompanion(entry);
34
+ await settleFeedbackCompanion(entry, ticketResult.kind === 'canceled' ? 'canceled' : 'approved');
34
35
  // The ROW is what carries status; `getNode` would answer from a pruned
35
36
  // bridge's surviving meta.json with no status at all.
36
37
  const bridge = getRow(entry);
@@ -89,7 +89,7 @@ async function finishApproved(review, result) {
89
89
  }
90
90
  }
91
91
  current = requireVisibleReview(review.review_id);
92
- retireCompanion(current.companion_node_id, 'approved');
92
+ await retireCompanion(current.companion_node_id, 'approved');
93
93
  return requireVisibleReview(review.review_id);
94
94
  }
95
95
  async function finishCanceled(review, winningWrite, args) {
@@ -150,7 +150,7 @@ async function finishCanceled(review, winningWrite, args) {
150
150
  current = requireVisibleReview(review.review_id);
151
151
  if (canceledTicket === undefined)
152
152
  canceledTicket = ticketResult(current);
153
- retireCompanion(current.companion_node_id, 'canceled');
153
+ await retireCompanion(current.companion_node_id, 'canceled');
154
154
  return { record: requireVisibleReview(review.review_id), ...(canceledTicket === undefined ? {} : { ticket_result: canceledTicket }) };
155
155
  }
156
156
  /** Tell the origin its review was submitted but is finishing behind the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.203",
3
+ "version": "0.3.204",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.203",
3
+ "version": "0.3.204",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.203",
9
+ "version": "0.3.204",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {