@north-light/crouter 0.3.301 → 0.3.302

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.
@@ -954,12 +954,9 @@ export function autoDropCleanManagedWorktreeLocked(nodeId, wt, proof) {
954
954
  * `open_managed_worktree` guard then still applies unchanged — real
955
955
  * commits, real uncommitted changes, and a lost race all require the
956
956
  * explicit path. */
957
- function cleanCurrentBranchTip(wt) {
957
+ function cleanCurrentCheckoutTip(wt) {
958
958
  if (wt.state !== 'open' || !existsSync(wt.path) || isRebaseInProgress(wt.path))
959
959
  return null;
960
- const branch = gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path);
961
- if (branch.status !== 0 || branch.stdout.trim() !== wt.branch)
962
- return null;
963
960
  const status = gitSync(['status', '--porcelain'], wt.path);
964
961
  if (status.status !== 0 || status.stdout.trim() !== '')
965
962
  return null;
@@ -967,7 +964,7 @@ function cleanCurrentBranchTip(wt) {
967
964
  return head.status === 0 ? head.stdout.trim() : null;
968
965
  }
969
966
  export async function branchIsPushedAtCurrentTip(wt) {
970
- const initialTip = cleanCurrentBranchTip(wt);
967
+ const initialTip = cleanCurrentCheckoutTip(wt);
971
968
  if (initialTip === null)
972
969
  return false;
973
970
  // A cached origin branch can be stale in either direction. Query origin's
@@ -989,7 +986,7 @@ export async function branchIsPushedAtCurrentTip(wt) {
989
986
  return false;
990
987
  // The remote proof is useful only if the local checkout is still the exact
991
988
  // clean tip it inspected when finalization begins.
992
- return cleanCurrentBranchTip(wt) === initialTip;
989
+ return cleanCurrentCheckoutTip(wt) === initialTip;
993
990
  }
994
991
  export function autoDropCleanManagedWorktree(nodeId) {
995
992
  const wt = openManagedWorktreeForNode(nodeId);
@@ -29,7 +29,7 @@ import { CrtrClient } from '../../../../api/index.js';
29
29
  import { registerPush } from '../../../../commands/push.js';
30
30
  import { registerNode } from '../../../../commands/node.js';
31
31
  import { createNode, getNode } from '../../../../core/canvas/canvas.js';
32
- import { createManagedWorktree } from '../../../../core/worktree.js';
32
+ import { branchIsPushedAtCurrentTip, createManagedWorktree } from '../../../../core/worktree.js';
33
33
  import { readInboxSince } from '../../../../core/feed/inbox.js';
34
34
  import { gitSync } from '../../../../core/git.js';
35
35
  import { InputError } from '../../../../core/io.js';
@@ -187,28 +187,38 @@ test('re-homed guard: `crtr push final` on a node with a managed worktree holdin
187
187
  rmSync(repo, { recursive: true, force: true });
188
188
  }
189
189
  });
190
- test('`crtr push final` concludes a clean managed worktree whose tip exists on origin only under a different branch name (#912)', async () => {
190
+ test('`crtr push final` retains a clean managed worktree checked out on a pushed PR branch (#1109, #1061)', async () => {
191
191
  const repo = initRepo();
192
192
  const remote = mkdtempSync(join(tmpdir(), 'crtr-leafparity-remote-'));
193
193
  try {
194
194
  git(['init', '--bare'], remote);
195
195
  git(['remote', 'add', 'origin', remote], repo);
196
196
  git(['push', '-u', 'origin', 'main'], repo);
197
- const id = 'wt-912-differently-named-remote-branch';
197
+ const id = 'wt-pushed-pr-branch-final';
198
198
  const wt = createManagedWorktree(repo, id);
199
199
  writeFileSync(join(wt.path, 'delivered-via-pr.txt'), 'delivered via a PR branch\n');
200
200
  git(['add', '.'], wt.path);
201
- git(['commit', '-m', 'delivered via differently named branch'], wt.path);
201
+ git(['commit', '-m', 'delivered via PR branch'], wt.path);
202
202
  const tip = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
203
- const remoteBranch = 'fix/issue-912-worktree-final';
204
- git(['push', 'origin', `HEAD:refs/heads/${remoteBranch}`], wt.path);
205
- assert.equal(gitSync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${wt.branch}`], wt.path).status, 2, 'the recorded crtr branch is absent from origin');
206
- assert.equal(gitSync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${remoteBranch}`], wt.path).stdout.split('\t')[0], tip, 'origin carries the exact tip under the PR branch name');
203
+ const prBranch = 'fix/pushed-pr-worktree-final';
204
+ git(['switch', '-c', prBranch], wt.path);
205
+ git(['push', 'origin', `HEAD:refs/heads/${wt.branch}`], wt.path);
206
+ git(['push', 'origin', `HEAD:refs/heads/${prBranch}`], wt.path);
207
+ assert.equal(gitSync(['rev-parse', `refs/heads/${wt.branch}`], wt.path).stdout.trim(), tip, 'the recorded branch remains at the checkout tip');
208
+ assert.equal(gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path).stdout.trim(), prBranch, 'the checkout uses the PR branch instead of the recorded branch');
209
+ assert.equal(gitSync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${wt.branch}`], wt.path).stdout.split('\t')[0], tip, 'origin carries the recorded branch tip');
210
+ assert.equal(gitSync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${prBranch}`], wt.path).stdout.split('\t')[0], tip, 'origin carries the PR branch tip');
211
+ const untracked = join(wt.path, 'generated.uid');
212
+ writeFileSync(untracked, 'untracked generated file\n');
213
+ assert.equal(await branchIsPushedAtCurrentTip(wt), false, 'an untracked file still rejects the origin-retention proof');
214
+ rmSync(untracked);
207
215
  createNode(worktreeNode(id, wt.path, wt));
208
- const out = await runPushAs(id, 'final', 'delivered through a differently named PR branch');
216
+ const out = await runPushAs(id, 'final', 'delivered through a PR branch');
209
217
  assert.equal(out['worktree_auto_dropped'], undefined, 'the remote-tip allowance retains the open worktree');
210
- assert.equal(getNode(id)?.managed_worktree?.state, 'open', 'the remote-tip allowance does not close the worktree');
211
- assert.equal(getNode(id)?.status, 'done', 'the node finalizes when origin carries its exact tip under the PR branch name');
218
+ assert.equal(getNode(id)?.managed_worktree?.state, 'open', 'the remote-tip allowance does not close the worktree record');
219
+ assert.equal(getNode(id)?.managed_worktree?.branch, wt.branch, 'the retained record keeps its recorded branch');
220
+ assert.equal(gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path).stdout.trim(), prBranch, 'finalization does not change the checked-out PR branch');
221
+ assert.equal(getNode(id)?.status, 'done', 'the node finalizes when both pushed branches have its exact tip');
212
222
  }
213
223
  finally {
214
224
  rmSync(repo, { recursive: true, force: true });
@@ -100,7 +100,7 @@ async function guardFinalWorktree(id) {
100
100
  // command-local mapper needed (plan C-5).
101
101
  throw new InputError({
102
102
  error: 'open_managed_worktree',
103
- message: 'This node still owns an open managed worktree — `push final` is blocked until it is closed.',
103
+ message: 'This node still owns an open managed worktree that is neither clean and locally landed nor clean with its exact tip on origin — `push final` cannot finish.',
104
104
  next: (() => {
105
105
  const wt = getNode(id)?.managed_worktree;
106
106
  return wt !== null && wt !== undefined && wt.state === 'open' && !existsSync(wt.path)
@@ -1,7 +1,7 @@
1
1
  import type { ManagedWorktree, NodeMeta, NodeRow, NodeRuntime, SubscriptionRef } from '../../core/canvas/types.js';
2
2
  import type { OutcomeDeliveryRecord } from '../../core/canvas/node-outcome-deliveries.js';
3
3
  import type { ReviewComment, ReviewCommentEvent, ReviewErrorCode, ReviewRecord, ReviewResult } from '../../core/review/types.js';
4
- import type { Cron, CronRunRecord } from '../../core/canvas/crons.js';
4
+ import { type Cron, type CronRunRecord } from '../../core/canvas/crons.js';
5
5
  import type { ProfileEntry } from '../../core/profiles/manifest.js';
6
6
  import type { ReviveResult } from '../../core/runtime/revive.js';
7
7
  import type { CloseNodeResult } from '../../core/runtime/close.js';
@@ -12,6 +12,7 @@
12
12
  import { existsSync, readFileSync } from 'node:fs';
13
13
  import { outputResultPath } from '../../core/runtime/structured-output.js';
14
14
  import { ReviewOperationError as ReviewOperationErrorClass } from '../../core/review/types.js';
15
+ import { cronSinkDisplay } from '../../core/canvas/crons.js';
15
16
  import { isProfilePaused, sanitizeProfileMetadata } from '../../core/profiles/manifest.js';
16
17
  import { contextDir, inboxPath, nodeDir, nodeMetaPath, reportsDir, reviewResultPath, transcriptPath, viewSocketPath, } from '../../core/canvas/paths.js';
17
18
  import { FinalizationError } from '../../core/feed/feed.js';
@@ -21,7 +22,6 @@ import { CrtrError } from '../../core/errors.js';
21
22
  import { WorktreeError } from '../../core/worktree.js';
22
23
  import { ApiError } from '../../api/errors.js';
23
24
  import { ExitCode } from '../../types.js';
24
- import { sinkDisplay } from '../cron-sink.js';
25
25
  import { responsePath } from '../../core/human/convention.js';
26
26
  import { ticketDir } from '../../core/human/root.js';
27
27
  // Node projections
@@ -314,7 +314,7 @@ export function toCronDTO(c, lastRun) {
314
314
  run_timeout_s: c.run_timeout_s,
315
315
  overlap: c.overlap,
316
316
  on_output: c.on_output,
317
- sink: sinkDisplay(c.sink),
317
+ sink: cronSinkDisplay(c.sink),
318
318
  tier: c.tier,
319
319
  state: c.state,
320
320
  held: c.held,
@@ -1,2 +1,2 @@
1
- import type { Cron } from '../../core/canvas/crons.js';
1
+ import { type Cron } from '../../core/canvas/crons.js';
2
2
  export declare function deliverToSink(c: Cron, stdout: string): Promise<string>;
@@ -13,7 +13,7 @@ import { reviveNode } from '../../core/runtime/revive.js';
13
13
  import { isBrokerLive } from '../../core/runtime/model-swap.js';
14
14
  import { spawnChild } from '../../core/runtime/spawn.js';
15
15
  import { cronWakeOrigin } from '../../core/runtime/bearings.js';
16
- import { parseSink } from '../cron-sink.js';
16
+ import { parseCronSink } from '../../core/canvas/crons.js';
17
17
  import { resolveLiveProfile, resolveLiveProfileName } from './armed-context.js';
18
18
  async function deliverNodeSink(c, target, stdout) {
19
19
  const meta = getNode(target);
@@ -89,7 +89,7 @@ function deliverHumanSink(c, stdout) {
89
89
  publishPage(ticketDir(id), prepared);
90
90
  }
91
91
  export async function deliverToSink(c, stdout) {
92
- const sink = parseSink(c.sink);
92
+ const sink = parseCronSink(c.sink);
93
93
  if (sink === null)
94
94
  return 'no sink armed — nothing delivered';
95
95
  if (sink.kind === 'node') {
@@ -1,4 +1,4 @@
1
- import { newestReportRef, hasActiveLiveSubscription, hasLiveMessageWait, hasLiveSubscriber, hasPendingCancelOnWakeCron, subscribersOf, } from '../../core/canvas/index.js';
1
+ import { newestReportRef, hasActiveLiveSubscription, hasLiveMessageWait, hasLiveSubscriber, hasPendingCronWake, subscribersOf, } from '../../core/canvas/index.js';
2
2
  import { fullName } from '../../core/canvas/labels.js';
3
3
  import { countTickets } from '../../core/canvas/attention.js';
4
4
  import { hasUnseenWakeMail, readCursor, readInboxSince } from '../../core/feed/inbox.js';
@@ -12,7 +12,7 @@ import { transition } from '../../core/runtime/lifecycle.js';
12
12
  export function hasLiveObligation(nodeId) {
13
13
  return hasActiveLiveSubscription(nodeId)
14
14
  || hasLiveMessageWait(nodeId)
15
- || hasPendingCancelOnWakeCron(nodeId)
15
+ || hasPendingCronWake(nodeId)
16
16
  || countTickets(nodeId) > 0
17
17
  || readOutputRequest(nodeId).state !== 'absent';
18
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.301",
3
+ "version": "0.3.302",
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.301",
3
+ "version": "0.3.302",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.301",
9
+ "version": "0.3.302",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "workspaces": [
@@ -5206,17 +5206,17 @@
5206
5206
  },
5207
5207
  "packages/crouter-api": {
5208
5208
  "name": "@north-light/crouter-api",
5209
- "version": "0.3.301",
5209
+ "version": "0.3.302",
5210
5210
  "license": "UNLICENSED"
5211
5211
  },
5212
5212
  "packages/crouter-env-docker": {
5213
5213
  "name": "@north-light/crouter-env-docker",
5214
- "version": "0.3.301",
5214
+ "version": "0.3.302",
5215
5215
  "license": "UNLICENSED"
5216
5216
  },
5217
5217
  "packages/crouter-sdk": {
5218
5218
  "name": "@north-light/crouter-sdk",
5219
- "version": "0.3.301",
5219
+ "version": "0.3.302",
5220
5220
  "license": "UNLICENSED",
5221
5221
  "dependencies": {
5222
5222
  "@north-light/crouter-api": "^0.3.295"
@@ -1,26 +0,0 @@
1
- /** The one decoder for a cron's stored sink JSON.
2
- *
3
- * A sink is armed as a CLI spec string (`node:<id>` | `spawn:<kind>` |
4
- * `human`), validated and encoded to JSON once at arm time
5
- * (`api/handlers/crons.ts`), and thereafter read back in exactly two places:
6
- * the runner, which dispatches a delivery on it, and the DTO mapper, which
7
- * renders it. Both go through here so the stored shape has a single reader.
8
- *
9
- * A malformed stored value decodes as no-sink rather than throwing: a cron
10
- * that cannot deliver must still run, and must still list.
11
- */
12
- export type ParsedSink = {
13
- kind: 'node';
14
- node: string;
15
- } | {
16
- kind: 'spawn';
17
- spawn: string;
18
- } | {
19
- kind: 'human';
20
- } | null;
21
- /** Stored JSON → typed shape. `{}`, malformed JSON, and an unrecognised shape
22
- * all read as null (no sink). */
23
- export declare function parseSink(sinkJson: string): ParsedSink;
24
- /** Stored JSON → the display spec the CLI armed with, or null when the cron
25
- * never delivers. The exact inverse of the arm-time encoding. */
26
- export declare function sinkDisplay(sinkJson: string): string | null;
@@ -1,43 +0,0 @@
1
- /** The one decoder for a cron's stored sink JSON.
2
- *
3
- * A sink is armed as a CLI spec string (`node:<id>` | `spawn:<kind>` |
4
- * `human`), validated and encoded to JSON once at arm time
5
- * (`api/handlers/crons.ts`), and thereafter read back in exactly two places:
6
- * the runner, which dispatches a delivery on it, and the DTO mapper, which
7
- * renders it. Both go through here so the stored shape has a single reader.
8
- *
9
- * A malformed stored value decodes as no-sink rather than throwing: a cron
10
- * that cannot deliver must still run, and must still list.
11
- */
12
- /** Stored JSON → typed shape. `{}`, malformed JSON, and an unrecognised shape
13
- * all read as null (no sink). */
14
- export function parseSink(sinkJson) {
15
- try {
16
- const s = JSON.parse(sinkJson);
17
- if (typeof s['node'] === 'string')
18
- return { kind: 'node', node: s['node'] };
19
- if (typeof s['spawn'] === 'string')
20
- return { kind: 'spawn', spawn: s['spawn'] };
21
- if (s['human'] === true)
22
- return { kind: 'human' };
23
- }
24
- catch {
25
- // malformed stored sink reads as no-sink; callers report it in their own terms
26
- }
27
- return null;
28
- }
29
- /** Stored JSON → the display spec the CLI armed with, or null when the cron
30
- * never delivers. The exact inverse of the arm-time encoding. */
31
- export function sinkDisplay(sinkJson) {
32
- const sink = parseSink(sinkJson);
33
- if (sink === null)
34
- return null;
35
- switch (sink.kind) {
36
- case 'node':
37
- return `node:${sink.node}`;
38
- case 'spawn':
39
- return `spawn:${sink.spawn}`;
40
- case 'human':
41
- return 'human';
42
- }
43
- }