Compare commits
11 Commits
9070c4274c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bbc41646ee | |||
| 0e8e4a5d7b | |||
| f1ec3963f5 | |||
| fc455841e2 | |||
| 7946667fe3 | |||
| b3689733a7 | |||
| c538e23231 | |||
| 1601555d1e | |||
| beb517478e | |||
| d575ddd0f5 | |||
| f379a6d059 |
@@ -0,0 +1,149 @@
|
||||
# Gitea Actions release workflow for ctask.
|
||||
#
|
||||
# Triggered by pushing a tag matching v*. Builds release artifacts using the
|
||||
# same scripts/build-release.sh that operators run locally, then creates a
|
||||
# Gitea release via the API and uploads the four artifacts.
|
||||
#
|
||||
# Design notes:
|
||||
# - Pure shell + Gitea API. No actions/checkout, no actions/setup-go, no
|
||||
# softprops/action-gh-release or any third-party action.
|
||||
# - Tag is parsed from `gitea.ref` (strip `refs/tags/` prefix). The plan
|
||||
# pins this explicitly: do not depend on `gitea.ref_name`.
|
||||
# - RC tags (matching `-rc`) are deletable: if a release for the tag
|
||||
# already exists, it's deleted and recreated. Final tags are
|
||||
# immutable — the workflow refuses to overwrite an existing release.
|
||||
# - RC tags are marked `prerelease: true` on the Gitea release page.
|
||||
# - Runner label `ctask-release` is provisioned by the shared
|
||||
# vps-act-runner-01 host, backed by golang:1.26-bookworm. jq is not
|
||||
# in that base image so it's installed at the top of the job.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ctask-release
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
API: https://git.typebased.dev/api/v1
|
||||
REPO: typebasedio/ctask
|
||||
steps:
|
||||
- name: Install jq
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends jq
|
||||
fi
|
||||
jq --version
|
||||
|
||||
- name: Derive tag from gitea.ref
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ gitea.ref }}"
|
||||
TAG="${TAG#refs/tags/}"
|
||||
echo "TAG=${TAG}" >> "$GITHUB_ENV"
|
||||
echo "Building release for tag: ${TAG}"
|
||||
|
||||
- name: Checkout source at tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --depth 1 --branch "${TAG}" \
|
||||
https://git.typebased.dev/typebasedio/ctask.git .
|
||||
git rev-parse HEAD
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go test ./...
|
||||
go vet ./...
|
||||
|
||||
- name: Build artifacts
|
||||
run: bash scripts/build-release.sh "${TAG}"
|
||||
|
||||
- name: Create release and upload artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# If a release already exists for this tag, decide what to do:
|
||||
# RC tags (anything containing '-rc') are deletable and
|
||||
# recreatable. Final tags are immutable.
|
||||
EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API}/repos/${REPO}/releases/tags/${TAG}")
|
||||
|
||||
if [ "${EXISTING}" = "200" ]; then
|
||||
if echo "${TAG}" | grep -q -- '-rc'; then
|
||||
EXISTING_ID=$(curl -sfS \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API}/repos/${REPO}/releases/tags/${TAG}" | jq -r '.id')
|
||||
curl -sfS -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API}/repos/${REPO}/releases/${EXISTING_ID}"
|
||||
echo "Deleted existing RC release ${EXISTING_ID}"
|
||||
else
|
||||
echo "ERROR: Release ${TAG} already exists and is not an RC. Refusing to overwrite." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Mark RC tags as prereleases on the Gitea release page.
|
||||
if echo "${TAG}" | grep -q -- '-rc'; then
|
||||
PRERELEASE=true
|
||||
else
|
||||
PRERELEASE=false
|
||||
fi
|
||||
|
||||
BODY=$(jq -nc \
|
||||
--arg tag "${TAG}" \
|
||||
--argjson pre "${PRERELEASE}" \
|
||||
'{tag_name:$tag, name:$tag, draft:false, prerelease:$pre}')
|
||||
|
||||
RELEASE_ID=$(curl -sfS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${BODY}" \
|
||||
"${API}/repos/${REPO}/releases" | jq -r '.id')
|
||||
|
||||
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
|
||||
echo "ERROR: failed to create release for ${TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Created release ${RELEASE_ID} for ${TAG}"
|
||||
|
||||
for file in \
|
||||
dist/ctask-linux-amd64 \
|
||||
dist/ctask-windows-amd64.exe \
|
||||
dist/checksums-sha256.txt \
|
||||
dist/release-manifest.json; do
|
||||
filename=$(basename "${file}")
|
||||
echo "Uploading ${filename}..."
|
||||
curl -sfS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${file}" \
|
||||
"${API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" \
|
||||
> /dev/null
|
||||
done
|
||||
echo "Uploaded 4 assets to release ${RELEASE_ID}"
|
||||
|
||||
- name: Verify published artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE="https://git.typebased.dev/typebasedio/ctask/releases/download"
|
||||
rm -rf /tmp/verify
|
||||
mkdir -p /tmp/verify
|
||||
cd /tmp/verify
|
||||
curl -fL "${BASE}/${TAG}/ctask-linux-amd64" -o ctask-linux-amd64
|
||||
curl -fL "${BASE}/${TAG}/checksums-sha256.txt" -o checksums-sha256.txt
|
||||
curl -fL "${BASE}/${TAG}/release-manifest.json" -o release-manifest.json
|
||||
sha256sum -c checksums-sha256.txt --ignore-missing
|
||||
jq -e . release-manifest.json > /dev/null
|
||||
chmod +x ctask-linux-amd64
|
||||
REPORTED="$(./ctask-linux-amd64 --version)"
|
||||
echo "Published binary reports: ${REPORTED}"
|
||||
echo "${REPORTED}" | grep -qF "${TAG}"
|
||||
echo "Download verification passed for ${TAG}"
|
||||
@@ -12,7 +12,7 @@ This project is a local-only Go CLI. It is not published to any package registry
|
||||
### Install on Windows
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Warren\claude_tasks\ctask_v0.1
|
||||
cd C:\Users\Warren\claude_tasks\ctask
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install.ps1
|
||||
```
|
||||
|
||||
@@ -29,7 +29,7 @@ This builds from the local repo, copies files to `%LOCALAPPDATA%\ctask\bin`, and
|
||||
### Uninstall
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Warren\claude_tasks\ctask_v0.1
|
||||
cd C:\Users\Warren\claude_tasks\ctask
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/uninstall.ps1
|
||||
```
|
||||
|
||||
|
||||
@@ -6,22 +6,24 @@ ctask gives developers dedicated directories with consistent structure, visible
|
||||
|
||||
## Status
|
||||
|
||||
v0.2.0 -- local use, not published to any package registry. Windows-primary, cross-platform design.
|
||||
v0.6.0 -- local use, not published to any package registry. Windows-primary, cross-platform design.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Named workspaces** with consistent layout (task metadata, notes, context, output, logs)
|
||||
- **Named workspaces** with consistent layout (task metadata, agent instructions, notes, context, output, logs)
|
||||
- **Session traceability** -- automatic file-change snapshot logging on every session
|
||||
- **Agent-agnostic** -- default agent is Claude Code, but any CLI agent or shell works
|
||||
- **Agent-agnostic** -- built-in profiles for Claude Code and OpenCode, plus a `custom` escape hatch for any CLI agent or shell
|
||||
- **Global config** -- optional user-level config file for defaults (agent, roots, session mode), with source attribution in `doctor`
|
||||
- **Session recovery** -- PID-aware lease detection: a workspace whose owner process has died is recovered immediately, with no stale-wait
|
||||
- **Query resolution** -- find workspaces by name, slug, or substring
|
||||
- **Status line** -- persistent session context inside Claude Code's UI
|
||||
- **Doctor** -- verify setup and diagnose configuration problems
|
||||
- **Doctor** -- verify setup, diagnose configuration problems, and validate agent setup
|
||||
- **Safe delete** -- active workspace protection prevents accidental data loss
|
||||
|
||||
## Install (Windows)
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Warren\claude_tasks\ctask_v0.1
|
||||
cd C:\Users\Warren\claude_tasks\ctask
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install.ps1
|
||||
```
|
||||
|
||||
@@ -55,11 +57,16 @@ ctask resume auth-bug
|
||||
| `ctask new [title]` | Create a new workspace and launch the agent |
|
||||
| `ctask list` | Show recent workspaces |
|
||||
| `ctask resume <query>` | Reopen a workspace and launch the agent |
|
||||
| `ctask open <query>` | Open a workspace in a shell (no agent) |
|
||||
| `ctask info <query>` | Display workspace metadata |
|
||||
| `ctask archive <query>` | Mark a workspace as archived |
|
||||
| `ctask last` | Resume the most recently updated workspace |
|
||||
| `ctask doctor` | Verify ctask setup |
|
||||
| `ctask open <query>` | Open a workspace directory without launching the agent |
|
||||
| `ctask attach <workspace>` | Attach to a workspace via tmux (persistent session) |
|
||||
| `ctask info <query>` | Display workspace metadata and path |
|
||||
| `ctask path <query>` | Print the absolute filesystem path of a workspace |
|
||||
| `ctask notes <query>` | Print a workspace's notes.md to stdout |
|
||||
| `ctask archive <query>` | Mark a workspace as archived |
|
||||
| `ctask restore <query>` | Un-archive a workspace (set status back to active) |
|
||||
| `ctask agents check [workspace]` | Validate the agent configuration for a workspace |
|
||||
| `ctask doctor` | Verify ctask setup and diagnose configuration problems |
|
||||
| `ctask delete <query>` | Permanently remove a workspace |
|
||||
|
||||
See [docs/commands.md](docs/commands.md) for full usage.
|
||||
@@ -77,7 +84,7 @@ Run `ctask doctor` to check if the status line is configured. See [docs/install.
|
||||
## Uninstall
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Warren\claude_tasks\ctask_v0.1
|
||||
cd C:\Users\Warren\claude_tasks\ctask
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/uninstall.ps1
|
||||
```
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -61,8 +62,12 @@ func TestInfoShowsActivePersistentSessionWithAttachHint(t *testing.T) {
|
||||
wsDir := makeInfoSessionWorkspace(t, root, "active-persist")
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
// An active session is owned by a live process: lease freshness is
|
||||
// PID-aware (v0.6 Phase 3), so a local-hostname lease must point at a
|
||||
// live PID to read as active. Use the test process itself.
|
||||
livePID := os.Getpid()
|
||||
writeLeaseAtForCmdTest(t, wsDir, &session.Lease{
|
||||
PID: 12345,
|
||||
PID: livePID,
|
||||
Hostname: session.CurrentHostname(),
|
||||
Mode: "persistent",
|
||||
StartedAt: now,
|
||||
@@ -76,7 +81,7 @@ func TestInfoShowsActivePersistentSessionWithAttachHint(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"Session: active",
|
||||
"Mode: persistent",
|
||||
"Owner: pid 12345", // local host -> hostname omitted
|
||||
fmt.Sprintf("Owner: pid %d", livePID), // local host -> hostname omitted
|
||||
"Attach: ctask attach active-persist",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
@@ -91,8 +96,10 @@ func TestInfoShowsActiveDirectSessionWithoutAttachHint(t *testing.T) {
|
||||
wsDir := makeInfoSessionWorkspace(t, root, "active-direct")
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
// Live PID required for an active local-hostname lease — see
|
||||
// TestInfoShowsActivePersistentSessionWithAttachHint.
|
||||
writeLeaseAtForCmdTest(t, wsDir, &session.Lease{
|
||||
PID: 8888,
|
||||
PID: os.Getpid(),
|
||||
Hostname: session.CurrentHostname(),
|
||||
Mode: "direct",
|
||||
StartedAt: now,
|
||||
|
||||
@@ -47,17 +47,25 @@ func TestListSessionColumnShowsModeAndStaleAndDash(t *testing.T) {
|
||||
staleWS := makeListSessionWorkspace(t, root, "general", "2026-05-12_stale-ws", "stale-ws", "active")
|
||||
makeListSessionWorkspace(t, root, "general", "2026-05-11_idle-ws", "idle-ws", "active")
|
||||
|
||||
// persist-ws and direct-ws must read as active sessions: a fresh
|
||||
// heartbeat alone is not enough now that lease freshness is PID-aware
|
||||
// (v0.6 Phase 3). Use the live test process PID so the local-hostname
|
||||
// lease passes the PID-liveness check, mirroring
|
||||
// session.TestInspectLeaseFreshLocal.
|
||||
livePID := os.Getpid()
|
||||
writeLeaseAtForCmdTest(t, persistWS, &session.Lease{
|
||||
PID: 1, Hostname: host, Mode: "persistent",
|
||||
PID: livePID, Hostname: host, Mode: "persistent",
|
||||
StartedAt: now, LastHeartbeatAt: now,
|
||||
})
|
||||
writeLeaseAtForCmdTest(t, directWS, &session.Lease{
|
||||
PID: 2, Hostname: host, Mode: "direct",
|
||||
PID: livePID, Hostname: host, Mode: "direct",
|
||||
StartedAt: now, LastHeartbeatAt: now,
|
||||
})
|
||||
// stale-ws is wall-clock stale (heartbeat 10m ago); its PID is
|
||||
// irrelevant — wall-clock staleness wins unconditionally.
|
||||
heartbeat := now.Add(-10 * time.Minute)
|
||||
writeLeaseAtForCmdTest(t, staleWS, &session.Lease{
|
||||
PID: 3, Hostname: host, Mode: "direct",
|
||||
PID: livePID, Hostname: host, Mode: "direct",
|
||||
StartedAt: heartbeat, LastHeartbeatAt: heartbeat,
|
||||
})
|
||||
|
||||
|
||||
+47
-4
@@ -6,7 +6,17 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var version = "0.5.4"
|
||||
// version and commit hold the values injected at release build time
|
||||
// (via `-ldflags -X main.version=... -X main.commit=...` propagated through
|
||||
// SetVersionInfo) or the defaults below for local development builds.
|
||||
//
|
||||
// `version` is the release tag verbatim (e.g. "v0.6.1-rc.1"). For dev
|
||||
// builds it is the literal "dev". `commit` is the full git SHA at build
|
||||
// time, or empty when not injected.
|
||||
var (
|
||||
version = "dev"
|
||||
commit = ""
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "ctask",
|
||||
@@ -18,7 +28,40 @@ func Execute() error {
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Version = version
|
||||
rootCmd.SetVersionTemplate(fmt.Sprintf("ctask v%s\n", version))
|
||||
// SetVersionInfo is called from main() before Execute() to forward the
|
||||
// build-time-injected version and commit into the cmd package. Pass empty
|
||||
// strings to keep the package defaults.
|
||||
func SetVersionInfo(v, c string) {
|
||||
if v != "" {
|
||||
version = v
|
||||
}
|
||||
if c != "" {
|
||||
commit = c
|
||||
}
|
||||
applyVersionTemplate()
|
||||
}
|
||||
|
||||
// applyVersionTemplate rebuilds Cobra's --version output from the current
|
||||
// version/commit values. Format:
|
||||
//
|
||||
// ctask v0.6.1-rc.1 (abcdef1) // tagged build, commit known
|
||||
// ctask v0.6.1-rc.1 // tagged build, no commit injected
|
||||
// ctask dev // local build
|
||||
func applyVersionTemplate() {
|
||||
rootCmd.Version = version
|
||||
var line string
|
||||
if commit != "" {
|
||||
short := commit
|
||||
if len(short) > 7 {
|
||||
short = short[:7]
|
||||
}
|
||||
line = fmt.Sprintf("ctask %s (%s)\n", version, short)
|
||||
} else {
|
||||
line = fmt.Sprintf("ctask %s\n", version)
|
||||
}
|
||||
rootCmd.SetVersionTemplate(line)
|
||||
}
|
||||
|
||||
func init() {
|
||||
applyVersionTemplate()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A fresh-by-wall-clock local lease whose owner PID is dead must be
|
||||
// classified LeaseStateStale by InspectLease, so the persistent-mode
|
||||
// dispatcher routes to adoption immediately rather than after the 60s
|
||||
// wall-clock wait. (dispatchPersistent itself lives in cmd/ and is
|
||||
// covered there; this test pins the InspectLease half of the contract.)
|
||||
func TestInspectLeaseDeadLocalPIDIsStale(t *testing.T) {
|
||||
withCheckProcess(t, func(int) ProcessState { return ProcessDead })
|
||||
ws := t.TempDir()
|
||||
now := time.Now().UTC()
|
||||
writeLeaseAt(t, ws, &Lease{
|
||||
SessionID: "test",
|
||||
PID: 4242,
|
||||
Hostname: currentHostname(),
|
||||
LastHeartbeatAt: now, // fresh by wall-clock
|
||||
StartedAt: now,
|
||||
})
|
||||
if got := InspectLease(ws); got != LeaseStateStale {
|
||||
t.Errorf("dead-PID local lease: InspectLease = %v, want LeaseStateStale", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The control case: a fresh local lease with a live PID stays FreshLocal,
|
||||
// so passive reattach (not adoption) is chosen.
|
||||
func TestInspectLeaseLiveLocalPIDIsFreshLocal(t *testing.T) {
|
||||
withCheckProcess(t, func(int) ProcessState { return ProcessAlive })
|
||||
ws := t.TempDir()
|
||||
now := time.Now().UTC()
|
||||
writeLeaseAt(t, ws, &Lease{
|
||||
SessionID: "test",
|
||||
PID: 4242,
|
||||
Hostname: currentHostname(),
|
||||
LastHeartbeatAt: now,
|
||||
StartedAt: now,
|
||||
})
|
||||
if got := InspectLease(ws); got != LeaseStateFreshLocal {
|
||||
t.Errorf("live-PID local lease: InspectLease = %v, want LeaseStateFreshLocal", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// withCheckProcess swaps the package-level checkProcess seam for the
|
||||
// duration of a test and restores it on cleanup. Tests using it must not
|
||||
// run with t.Parallel().
|
||||
func withCheckProcess(t *testing.T, fn func(pid int) ProcessState) {
|
||||
t.Helper()
|
||||
orig := checkProcess
|
||||
checkProcess = fn
|
||||
t.Cleanup(func() { checkProcess = orig })
|
||||
}
|
||||
|
||||
func freshLocalLease(pid int) *Lease {
|
||||
now := time.Now().UTC()
|
||||
return &Lease{
|
||||
PID: pid,
|
||||
Hostname: currentHostname(),
|
||||
StartedAt: now,
|
||||
LastHeartbeatAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Wall-clock staleness wins unconditionally — existing behavior preserved.
|
||||
func TestIsStaleByWallClock(t *testing.T) {
|
||||
withCheckProcess(t, func(int) ProcessState { return ProcessAlive })
|
||||
now := time.Now().UTC()
|
||||
l := &Lease{PID: 999, Hostname: currentHostname(), LastHeartbeatAt: now.Add(-90 * time.Second)}
|
||||
if !IsStale(l, now, StaleLeaseAfter) {
|
||||
t.Error("a lease 90s past heartbeat must be stale even if PID is alive")
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh wall-clock + local host + dead owner PID => stale immediately.
|
||||
func TestIsStaleByDeadPID(t *testing.T) {
|
||||
withCheckProcess(t, func(int) ProcessState { return ProcessDead })
|
||||
l := freshLocalLease(4242)
|
||||
if !IsStale(l, time.Now().UTC(), StaleLeaseAfter) {
|
||||
t.Error("fresh local lease with a dead PID must be stale")
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh wall-clock + local host + live owner PID => not stale.
|
||||
func TestIsStaleFreshWithLivePID(t *testing.T) {
|
||||
withCheckProcess(t, func(int) ProcessState { return ProcessAlive })
|
||||
l := freshLocalLease(4242)
|
||||
if IsStale(l, time.Now().UTC(), StaleLeaseAfter) {
|
||||
t.Error("fresh local lease with a live PID must not be stale")
|
||||
}
|
||||
}
|
||||
|
||||
// Remote lease => PID check skipped entirely; wall-clock only.
|
||||
func TestIsStaleRemoteIgnoresPID(t *testing.T) {
|
||||
withCheckProcess(t, func(int) ProcessState {
|
||||
t.Error("checkProcess must not be called for a remote lease")
|
||||
return ProcessDead
|
||||
})
|
||||
now := time.Now().UTC()
|
||||
l := &Lease{PID: 4242, Hostname: "some-other-host", LastHeartbeatAt: now}
|
||||
if l.Hostname == currentHostname() {
|
||||
t.Skip("test hostname collision; cannot construct a remote lease")
|
||||
}
|
||||
if IsStale(l, now, StaleLeaseAfter) {
|
||||
t.Error("fresh remote lease must not be stale (PID check skipped)")
|
||||
}
|
||||
}
|
||||
|
||||
// Inconclusive PID check => conservative; fall back to wall-clock fresh.
|
||||
func TestIsStaleUnknownPIDConservative(t *testing.T) {
|
||||
withCheckProcess(t, func(int) ProcessState { return ProcessUnknown })
|
||||
l := freshLocalLease(4242)
|
||||
if IsStale(l, time.Now().UTC(), StaleLeaseAfter) {
|
||||
t.Error("fresh local lease with inconclusive PID check must not be stale")
|
||||
}
|
||||
}
|
||||
|
||||
// pid <= 0 => skip PID liveness entirely; wall-clock only.
|
||||
func TestIsStaleInvalidPIDSkipsCheck(t *testing.T) {
|
||||
withCheckProcess(t, func(int) ProcessState {
|
||||
t.Error("checkProcess must not be called when pid <= 0")
|
||||
return ProcessDead
|
||||
})
|
||||
now := time.Now().UTC()
|
||||
for _, pid := range []int{0, -1} {
|
||||
l := &Lease{PID: pid, Hostname: currentHostname(), LastHeartbeatAt: now}
|
||||
if IsStale(l, now, StaleLeaseAfter) {
|
||||
t.Errorf("fresh local lease with pid=%d must not be stale (wall-clock only)", pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive: a nil lease is stale.
|
||||
func TestIsStaleNilLease(t *testing.T) {
|
||||
if !IsStale(nil, time.Now().UTC(), StaleLeaseAfter) {
|
||||
t.Error("nil lease must be reported stale")
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,41 @@ func IsFresh(l *Lease, now time.Time, threshold time.Duration) bool {
|
||||
return now.Sub(l.LastHeartbeatAt) <= threshold
|
||||
}
|
||||
|
||||
// IsStale reports whether the lease should be treated as stale. It
|
||||
// supplements the wall-clock heartbeat threshold with PID liveness:
|
||||
//
|
||||
// - A heartbeat older than threshold is stale (existing behavior).
|
||||
// - Otherwise, for a lease whose hostname matches the current host and
|
||||
// whose PID is valid (> 0), a dead owner PID makes the lease stale
|
||||
// immediately. This is the v0.6 lazy-cleanup signal: a Ctrl-C'd or
|
||||
// terminal-closed session is recognized without the 60s wall-clock
|
||||
// wait.
|
||||
// - Remote leases, leases with pid <= 0, and inconclusive PID checks
|
||||
// (ProcessUnknown) fall back to wall-clock freshness only.
|
||||
//
|
||||
// PID liveness can only flip a wall-clock-fresh lease to stale; it never
|
||||
// revives a wall-clock-stale lease. IsStale is the freshness predicate
|
||||
// for adoption and Layer-1 decisions — prefer it over a bare IsFresh
|
||||
// call at any site that decides whether a session is still owned.
|
||||
func IsStale(l *Lease, now time.Time, threshold time.Duration) bool {
|
||||
if l == nil {
|
||||
return true
|
||||
}
|
||||
if !IsFresh(l, now, threshold) {
|
||||
return true // wall-clock stale — existing behavior
|
||||
}
|
||||
if l.PID <= 0 {
|
||||
return false // no usable PID — wall-clock only
|
||||
}
|
||||
if l.Hostname != currentHostname() {
|
||||
return false // remote lease — wall-clock only
|
||||
}
|
||||
if checkProcess(l.PID) == ProcessDead {
|
||||
return true // local owner confirmed dead
|
||||
}
|
||||
return false // alive or inconclusive — conservative
|
||||
}
|
||||
|
||||
// CleanupStaleLease inspects the lease at path:
|
||||
// - missing file: no-op, returns (nil, nil)
|
||||
// - corrupt / unparseable: removes the file, returns (nil, nil)
|
||||
@@ -154,7 +189,7 @@ func CleanupStaleLease(path string, staleAfter time.Duration) (*Lease, error) {
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if IsFresh(&l, time.Now(), staleAfter) {
|
||||
if !IsStale(&l, time.Now(), staleAfter) {
|
||||
return nil, nil
|
||||
}
|
||||
if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
|
||||
|
||||
@@ -56,7 +56,7 @@ func InspectLease(wsDir string) LeaseState {
|
||||
if l == nil {
|
||||
return LeaseStateNone
|
||||
}
|
||||
if !IsFresh(l, time.Now(), StaleLeaseAfter) {
|
||||
if IsStale(l, time.Now(), StaleLeaseAfter) {
|
||||
return LeaseStateStale
|
||||
}
|
||||
if l.Hostname != currentHostname() {
|
||||
|
||||
@@ -101,7 +101,7 @@ func runActiveLeaseCheck(opts PreflightOpts) (bool, bool, error) {
|
||||
existing, err := ReadLease(leasePath)
|
||||
switch {
|
||||
case err == nil && existing != nil:
|
||||
if IsFresh(existing, time.Now(), StaleLeaseAfter) {
|
||||
if !IsStale(existing, time.Now(), StaleLeaseAfter) {
|
||||
if opts.Force {
|
||||
return true, true, nil
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func statusAt(wsDir string, now time.Time) Status {
|
||||
}
|
||||
|
||||
state := SessionStateStale
|
||||
if IsFresh(&l, now, StaleLeaseAfter) {
|
||||
if !IsStale(&l, now, StaleLeaseAfter) {
|
||||
state = SessionStateActive
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,20 @@ import (
|
||||
"github.com/warrenronsiek/ctask/cmd"
|
||||
)
|
||||
|
||||
// version and commit are populated at release build time via:
|
||||
//
|
||||
// go build -ldflags "-X main.version=vX.Y.Z -X main.commit=<git-sha>"
|
||||
//
|
||||
// Defaults below apply when the binary is built without ldflags (e.g.
|
||||
// `go build`, `just install`) so local development still produces a
|
||||
// runnable binary whose `--version` output is unambiguous.
|
||||
var (
|
||||
version = "dev"
|
||||
commit = ""
|
||||
)
|
||||
|
||||
func main() {
|
||||
cmd.SetVersionInfo(version, commit)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
msg := err.Error()
|
||||
// Exit code 127 for agent command not found (per spec)
|
||||
@@ -21,4 +34,3 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# ctask — Session Handoff Notes
|
||||
|
||||
Last touched: 2026-05-14. **v0.6 Phase 1 is implemented and verified on branch `feat/v0.6-multi-agent-config` (5 commits, NOT yet merged into `main`). v0.5.4 remains the shipped tip on `main` and the installed binary is still `v0.5.4` — no version bump in Phase 1 (per spec, the bump lands at the end of Phase 3). Phase 2 is the next horizon and is explicitly NOT started.**
|
||||
Last touched: 2026-05-15. **v0.6.0 is SHIPPED. All three phases (config infrastructure, agent-agnostic layer, PID-liveness lazy-cleanup) merged into `main` at merge commit `b368973`, tagged `v0.6.0`. The installed binary at `%LOCALAPPDATA%\ctask\bin\ctask.exe` is now `v0.6.0`. The `feat/v0.6-multi-agent-config` branch has been deleted (fully merged). `main` is the only branch.**
|
||||
|
||||
## Where we are
|
||||
|
||||
- **`main`:** v0.5.4 (session-visibility polish). Tip at merge commit `10b7d5a`; version-bump commit `7704cd9`. Installed binary at `%LOCALAPPDATA%\ctask\bin\ctask.exe` is `v0.5.4`. Not refreshed during Phase 1 — the branch builds locally but has not been installed.
|
||||
- **Active branches:** `feat/v0.6-multi-agent-config` — 5 commits ahead of `main`, all under the v0.6 theme. Not yet merged. Phase 2 + Phase 3 work will continue on this branch.
|
||||
- **Pending action:** Phase 2 planning (multi-agent layer: agent profile system in task.yaml, `--agent` flag on `ctask new`, AGENTS.md + CLAUDE.md shim generation, `ctask agents check`). Phase 2 must NOT start until Phase 1 has been reviewed.
|
||||
- **`main`:** v0.6.0 (multi-agent core + config + context architecture + PID-liveness lazy-cleanup). Tip at merge commit `b368973 Merge branch 'feat/v0.6-multi-agent-config' into main`; tagged `v0.6.0`. Installed binary at `%LOCALAPPDATA%\ctask\bin\ctask.exe` refreshed to `v0.6.0` via `just install`.
|
||||
- **Active branches:** none — `feat/v0.6-multi-agent-config` was merged (`--no-ff`) and deleted. `main` is the only branch.
|
||||
- **Pending action:** none for v0.6. v0.7 (`ctask adopt` / `workspace.mode: adopted`) is the next theme per `v0.6-spec.md` Non-Goals.
|
||||
- Remote: none (local-only, intentional — see `CLAUDE.md`).
|
||||
- `ctask doctor` reports 5 pass/fail + 2 seed-directory + 1 `CTASK_PROJECT_ROOT` check + 1 `Session mode` INFO line + 1 tmux INFO/FAIL line (when persistent mode is configured) + the new v0.6 `── Settings ──` block with per-key source attribution.
|
||||
|
||||
@@ -156,7 +156,7 @@ Out of scope (deferred):
|
||||
### Known limitation (v0.5.3)
|
||||
|
||||
- **Refusal/bypass hints reflect `basename(os.Args[0])` for the command-form line, but descriptive prose ("ctask persistent mode requires...") and the ssh-remote hint stay hardcoded as "ctask".** Intentional — descriptive prose refers to program identity, and the ssh-remote `ctask` runs on the remote, not the local binary. **v0.5.4 audit confirmed this split is the right line** (spec §2 codified it: command-form via `invocationName()`, product-identity literal). Closed.
|
||||
- **Adoption requires waiting 60s for the previous owner's lease to go stale (`StaleLeaseAfter`).** A user who Ctrl-C's the foreground `ctask` and immediately re-runs `ctask resume` hits the v0.4 Layer-1 prompt instead of the adoption path. Acceptable but lazy-cleanup-unfriendly; deferred to v0.6 Phase 2 (see follow-ups). Not in v0.6 Phase 1 scope.
|
||||
- ~~**Adoption requires waiting 60s for the previous owner's lease to go stale (`StaleLeaseAfter`).**~~ **RESOLVED in v0.6 Phase 3.** A lease whose owner PID is confirmed dead on the local host is now treated as stale immediately via the PID-aware `IsStale` predicate — the 60s wall-clock wait no longer applies to a Ctrl-C'd / terminal-closed local session. See "What v0.6 Phase 3 delivered".
|
||||
|
||||
### What v0.5.4 delivered
|
||||
|
||||
@@ -306,6 +306,104 @@ Theme: **the multi-agent layer** — ctask is now agent-agnostic. task.yaml carr
|
||||
- **AGENTS.md is canonical; CLAUDE.md is a shim.** The shim exists only to point Claude Code at AGENTS.md. The seed-overlay rule still applies — a user seed dir's `AGENTS.md` / `CLAUDE.md` overrides the built-in.
|
||||
- **cobra's default `completion` command captures the output writer once.** `InitDefaultCompletionCmd` snapshots `c.OutOrStdout()` into the `bash`/`zsh`/etc. subcommand closures on the first `Execute()` anywhere in the process. Tests that drive `rootCmd.Execute()` with a redirected output buffer and then assert on completion output must drop the pre-created completion command first. `cmd/agents_check_test.go::captureRootCmd` restores `rootCmd`'s out/err/args on cleanup to limit this class of cross-test contamination.
|
||||
|
||||
### What v0.6 Phase 3 delivered (branch `feat/v0.6-multi-agent-config`, NOT merged)
|
||||
|
||||
Theme: **lazy-cleanup via PID liveness** — a lease whose owner process is
|
||||
confirmed dead on the local machine is treated as stale immediately,
|
||||
without the 60-second wall-clock wait. Closes the v0.5.3 known limitation
|
||||
(Ctrl-C / terminal-close then immediate `ctask resume`). The v0.6.0
|
||||
version bump rides along. Four commits; v0.6-spec.md §8 (context-file
|
||||
scaffolding) was already delivered in Phase 2 commit `0c6ed0c`, so §9
|
||||
(PID liveness) was the only remaining Phase 3 feature.
|
||||
|
||||
#### Commit list (oldest → newest)
|
||||
|
||||
- `9070c42` `feat(v0.6): tri-state PID liveness probe (ProcessAlive/Dead/Unknown)`
|
||||
- New `internal/session/pidcheck.go` (`ProcessState` tri-state +
|
||||
`checkProcess` test seam) and build-tagged platform files:
|
||||
`pidcheck_unix.go` (`syscall.Kill(pid, 0)` — `nil`/`EPERM`→Alive,
|
||||
`ESRCH`→Dead, else→Unknown) and `pidcheck_windows.go`
|
||||
(`syscall.OpenProcess` — opens→Alive, `ERROR_INVALID_PARAMETER`(87)→Dead,
|
||||
else→Unknown). Stdlib `syscall` only; no `golang.org/x/sys` dependency,
|
||||
`go.mod` unchanged.
|
||||
- `f379a6d` `feat(v0.6): IsStale supplements wall-clock freshness with PID liveness`
|
||||
- `IsStale(l, now, threshold)` in `lease.go`. Parameterized free
|
||||
function mirroring `IsFresh` (not the spec's zero-arg method form —
|
||||
deviation approved at plan review to preserve the package's
|
||||
injected-clock testability). PID liveness applies only to local
|
||||
leases (`l.Hostname == currentHostname()`) with `pid > 0`; remote
|
||||
leases, `pid <= 0`, and `ProcessUnknown` fall back to wall-clock.
|
||||
Wall-clock staleness is checked first and wins unconditionally — PID
|
||||
liveness only flips fresh → stale, never stale → fresh.
|
||||
- `d575ddd` `feat(v0.6): route lease-freshness callsites through IsStale`
|
||||
- Four freshness consumers now route through `IsStale`: `InspectLease`,
|
||||
`CleanupStaleLease`, `runActiveLeaseCheck`, and `statusAt`.
|
||||
`SessionStatus` / `ctask list` / `ctask info` reflect PID liveness
|
||||
automatically — only the one-line `statusAt` predicate swap was
|
||||
needed; the `Status` struct and all cmd-layer rendering are
|
||||
untouched. Also corrected three cmd-package session-display test
|
||||
fixtures (`list_session_test.go`, `info_session_test.go`) that built
|
||||
"active" leases with the local hostname but synthetic PIDs — now that
|
||||
freshness is PID-aware, an honest "active" fixture must use
|
||||
`os.Getpid()`.
|
||||
- `beb5174` `chore(v0.6): bump version to 0.6.0`
|
||||
- `cmd/root.go` `version` `0.5.4` → `0.6.0`. `ctask --version` reports
|
||||
`ctask v0.6.0`.
|
||||
|
||||
#### Verification (run on tip `beb5174`)
|
||||
|
||||
- `go test ./... -count=1` — all 8 packages `ok`, 0 failures.
|
||||
- `go vet ./...` — exit 0.
|
||||
- `just build` — `ctask.exe` (PE32+ x86-64).
|
||||
- `just build-linux` — `dist/ctask-linux-amd64`, statically linked ELF
|
||||
(the only check that compiles `pidcheck_unix.go`).
|
||||
- Binary smoke (v0.6.0 `ctask.exe`, real workspaces under a temp
|
||||
`CTASK_ROOT`) — **passed**. A lease carrying a reaped, genuinely-dead
|
||||
owner PID, the real host's hostname, and a 9-second-old heartbeat
|
||||
(unambiguously wall-clock-fresh) was auto-cleaned by `ctask resume`
|
||||
with `[ctask] Cleaned up stale session … last seen 9s ago` — no
|
||||
Layer-1 "Continue anyway?" prompt, no 60s wait, resume proceeded
|
||||
(exit 0). `ctask info` / `ctask list` on the same kind of lease report
|
||||
`stale` (they are read-only — they do not remove it). The dead PID
|
||||
alone (not wall-clock age) drove the staleness, confirming PID
|
||||
liveness end-to-end in the shipped binary.
|
||||
- Smoke finding worth keeping: on Windows, if another process holds an
|
||||
open handle to the dead owner (e.g. a parent that has not yet reaped
|
||||
it), `OpenProcess` still succeeds and `defaultCheckProcess` returns
|
||||
`ProcessAlive` — the documented conservative "zombie handle" case. The
|
||||
lease then falls back to wall-clock. The real Ctrl-C / terminal-close
|
||||
path has no such lingering handle, so PID liveness fires as intended.
|
||||
|
||||
#### Phase 3 constraints held
|
||||
|
||||
- Four-layer concurrency model unchanged — PID liveness only makes Layer
|
||||
1's "is this lease stale?" question smarter.
|
||||
- `StaleLeaseAfter` (60s) unchanged; PID liveness supplements it and
|
||||
remains the fallback for remote leases and inconclusive checks.
|
||||
- Lease creation, heartbeat, write lock, manifest, and summary shapes
|
||||
unchanged. `adopt.go` untouched.
|
||||
- Remote leases remain wall-clock-only (PID checks skipped when the lease
|
||||
hostname differs from the current host).
|
||||
- `IsFresh` retained as the pure wall-clock primitive `IsStale` builds on.
|
||||
- No new agent/profile/config/template work.
|
||||
|
||||
#### Architecture notes (worth preserving)
|
||||
|
||||
- **`IsStale` is the single freshness predicate** for stale-detection
|
||||
decisions. All four callsites route through it; `IsFresh` is now an
|
||||
internal building block (still exported, still directly tested).
|
||||
- **PID liveness is conservative by construction.** Only a definitive
|
||||
`ProcessDead` on a local lease shortcuts the wait. `ProcessUnknown`
|
||||
(permission errors, unexpected OS errors) and remote leases preserve
|
||||
the pre-v0.6 wall-clock behavior exactly.
|
||||
- **No `golang.org/x/sys` dependency.** Windows process probing uses
|
||||
stdlib `syscall.OpenProcess`; `PROCESS_QUERY_LIMITED_INFORMATION`
|
||||
(`0x1000`) and `ERROR_INVALID_PARAMETER` (`87`) are local constants.
|
||||
- **Known conservative edge cases** (acceptable — they never falsely
|
||||
declare a live owner dead): OS PID reuse reads the recycled PID as
|
||||
alive → wall-clock fallback; a Windows zombie handle reads as alive.
|
||||
- **Plan:** `docs/superpowers/plans/2026-05-15-v0.6-phase3-implementation.md`.
|
||||
|
||||
### Historical: original Phase 1 plan (now shipped — kept for traceability)
|
||||
|
||||
**Phase 1 scope (only thing to start next):**
|
||||
@@ -335,13 +433,15 @@ Covered in v0.4.1 notes. The exit-code gate (`childExitCode != 0 && startManifes
|
||||
|
||||
## Tree state at pause
|
||||
|
||||
- `main` tip is unchanged: `10b7d5a Merge branch 'feat/v0.5.4-session-visibility-polish' into main` (v0.5.4 shipped).
|
||||
- `feat/v0.6-multi-agent-config` is the active branch, 5 commits ahead of `main`. Tip `6182d89`. NOT merged — Phase 2 and Phase 3 will continue on this same branch per spec.
|
||||
- No tag pushed for v0.5.4 (no remote — the project is intentionally local-only per `CLAUDE.md`). v0.5.3 had `git tag v0.5.3` locally; v0.5.4 has none. No v0.6 tag yet — that's a post-Phase-3 task.
|
||||
- Installed `ctask.exe` at `%LOCALAPPDATA%\ctask\bin\ctask.exe` is still **v0.5.4** — Phase 1 did NOT refresh the installed binary. Local `ctask.exe` in the repo root is a `6182d89` build. `dist/ctask-linux-amd64` is the Phase-1 Linux cross-build (statically linked ELF).
|
||||
- Memory follow-ups (still live from v0.5.3, both relevant to v0.6 Phase 2 — see `memory/MEMORY.md`):
|
||||
- `feedback_design_for_lazy_cleanup` — drives v0.6 Phase 2 work on the 60s freshness wait + PID liveness.
|
||||
- `feedback_invocation_name_in_hints` — partially closed by the v0.5.4 audit (split between command-form and product-identity is now codified). Memory entry retained for the descriptive-prose question, which Phase 2 may revisit.
|
||||
- `main` tip is `b368973 Merge branch 'feat/v0.6-multi-agent-config' into main` — the v0.6.0 `--no-ff` merge commit. The merged feature work spanned 18 commits (Phase 1 ×6, Phase 2 ×7 incl. closeout, Phase 3 ×5 incl. closeouts).
|
||||
- Tag `v0.6.0` points at `b368973`. Local tags: `v0.5.3`, `v0.6.0` (no remote — local-only per `CLAUDE.md`; v0.5.4 was never tagged).
|
||||
- `feat/v0.6-multi-agent-config` has been deleted (`git branch -d`, fully merged; last tip was `c538e23`). `main` is the only branch.
|
||||
- Post-merge validation on `main`: `go test ./... -count=1` all 8 packages `ok`; `go vet` exit 0; `just build` + `just build-linux` clean; `just install` refreshed the installed binary; `ctask --version` → `ctask v0.6.0`. `ctask doctor` exits 1 with a single `[FAIL] AGENTS.md missing` — this is the legacy workspace `microcenter-gpu-watcher` (pre-v0.6, has `CLAUDE.md` but no `AGENTS.md`); v0.6 deliberately does not retrofit existing workspaces, so `agents check` correctly flags it. Not a regression.
|
||||
- No tag pushed for v0.5.4 (no remote — the project is intentionally local-only per `CLAUDE.md`). v0.5.3 had `git tag v0.5.3` locally; v0.5.4 has none. No v0.6 tag yet — a post-merge task.
|
||||
- Installed `ctask.exe` at `%LOCALAPPDATA%\ctask\bin\ctask.exe` is still **v0.5.4** — the v0.6 branch has NOT been installed. Local `ctask.exe` in the repo root is a `beb5174` build reporting `v0.6.0`. `dist/ctask-linux-amd64` is the Phase-3 Linux cross-build (statically linked ELF).
|
||||
- Memory follow-ups (see `memory/MEMORY.md`):
|
||||
- `feedback_design_for_lazy_cleanup` — the 60s-freshness-wait concern it raised is **addressed by v0.6 Phase 3** (PID-aware `IsStale`). The underlying principle (lifecycle UX must recover from Ctrl-C / terminal close) remains a live design value.
|
||||
- `feedback_invocation_name_in_hints` — partially closed by the v0.5.4 audit (split between command-form and product-identity is now codified). Memory entry retained for the descriptive-prose question.
|
||||
- Untracked files (do NOT touch without asking — pre-existing session-local working docs, unchanged from this session):
|
||||
- `.claude/settings.local.json` (modified — Claude Code local settings)
|
||||
- `bugfix-provisional-workspace.md` (spec for the 2026-04-22 initial provisional fix; may be deleted or archived)
|
||||
@@ -627,3 +727,112 @@ For the v0.4 surface:
|
||||
- **v0.5.4:** Do not move the Attach-hint string construction into `session.SessionStatus`. The cmd layer owns invocation-name rendering; the session package owns lease parsing. The boundary is what keeps SessionStatus testable without a `withInvocationName` seam.
|
||||
- **v0.5.4:** Do not re-introduce a hardcoded `"ctask"` in command-form hints. Use `invocationName()`. The regression tests pin a non-canonical name specifically to catch this.
|
||||
|
||||
---
|
||||
|
||||
# Release-Publishing Pipeline (plan: `ctask-release-pipeline-plan-final.md`)
|
||||
|
||||
## Phase 0 — Runner & Release API Preflight — **BLOCKED** (started 2026-05-19)
|
||||
|
||||
Tooling versions:
|
||||
- Workstation: go 1.26.3, git 2.53.0, curl 8.18.0, sha256sum (coreutils 8.32), jq 1.8.1, bash 5.2.37
|
||||
- VPS host (`netcup`): git 2.47.3, curl 8.14.1, sha256sum (coreutils 9.7), jq 1.7; **no Go on host**
|
||||
|
||||
Gitea instance:
|
||||
- Gitea 1.25.5 in Docker container `gitea` (`gitea/gitea:1.25.5`), fronted by Traefik.
|
||||
- API `https://git.typebased.dev/api/v1` reachable (200) from workstation and VPS.
|
||||
- No explicit `[actions]` section in app.ini -> Actions enabled by default (1.21+).
|
||||
- Org `typebasedio` -> 404 unauthenticated; repo `typebasedio/ctask` -> 404 (not created).
|
||||
|
||||
BLOCKERS (Phase 0 gate NOT satisfied):
|
||||
1. **No Gitea Actions runner exists** anywhere on the VPS — no `act_runner`
|
||||
container/binary/process/service. Phases 3-4 cannot run without a registered,
|
||||
labelled runner. Plan assumed a runner already existed.
|
||||
2. **Token value not located.** `workstation-dev-environment` token value needed
|
||||
for release API preflight + `RELEASE_TOKEN` secret. Awaiting user retrieval
|
||||
instructions (must not create a second token).
|
||||
|
||||
Open issues: runner install/registration decision; token retrieval source.
|
||||
|
||||
### Update 2026-05-20 — runner provisioned
|
||||
|
||||
Shared Typebased runner `vps-act-runner-01` came online (provisioned by the
|
||||
windows-dev agent; canonical docs in `typebasedio/windows-dev-setup` @ ae9eef5,
|
||||
`typebasedio/project-registry` @ 2ddc8c6). Contract for ctask:
|
||||
|
||||
- `runs-on: ctask-release`
|
||||
- Backed by `golang:1.26-bookworm` (jq NOT in base image — workflow installs it)
|
||||
- Pure Go workflow only; no Docker-in-job, no Docker image build steps
|
||||
- `RELEASE_TOKEN` repo/user secret used for release create/upload
|
||||
- Prior runner-registration token leak rotated; current credential is fresh
|
||||
|
||||
Phase 0 runner blocker resolved. Token still pending (Bitwarden locked at last
|
||||
check; user will unlock and we'll fetch by item name).
|
||||
|
||||
## Phase 2 — Local release build target — **DONE** (2026-05-20)
|
||||
|
||||
Added:
|
||||
|
||||
- `scripts/build-release.sh` — single recipe shared with CI; wipes `dist/`,
|
||||
cross-compiles `ctask-linux-amd64` and `ctask-windows-amd64.exe` with
|
||||
`CGO_ENABLED=0` and ldflags injecting `main.version` and `main.commit`,
|
||||
writes `checksums-sha256.txt` and `release-manifest.json`.
|
||||
- `scripts/release-check.sh` — runs `go test ./...`, `go vet ./...`,
|
||||
invokes `build-release.sh`, then asserts the binary's `--version`
|
||||
contains the requested tag. Falls back to the Windows binary on
|
||||
Windows hosts where the Linux binary can't exec.
|
||||
|
||||
## Phase 5 prerequisite — version injection — **DONE** (2026-05-20)
|
||||
|
||||
- `main.go` declares `var version = "dev"` and `var commit = ""` and forwards
|
||||
them to `cmd.SetVersionInfo` before `cmd.Execute()`.
|
||||
- `cmd/root.go` exposes `SetVersionInfo(v, c)` and rebuilds the Cobra version
|
||||
template via `applyVersionTemplate()`.
|
||||
- Output format (single line, decided pre-Phase 5):
|
||||
- `ctask v0.6.1-rc.1 (0e8e4a5)` — tagged build, commit known
|
||||
- `ctask v0.6.1-rc.1` — tagged build, no commit injected
|
||||
- `ctask dev` — local build (no ldflags)
|
||||
- Phase 5 Dockerfile exact-equality assertion (optional in the plan) will need
|
||||
to be a substring/prefix check because the commit is appended. Sample:
|
||||
`./ctask --version | grep -qF "${CTASK_VERSION}"`.
|
||||
- `just install` / local Windows workflow unchanged — local builds simply now
|
||||
print `ctask dev` instead of `ctask v0.6.0`. No behavioral regression.
|
||||
|
||||
### Local validation result (commit 0e8e4a5)
|
||||
|
||||
`bash scripts/release-check.sh v0.6.1-rc.1`:
|
||||
|
||||
- tests: all packages pass (`cmd`, `internal/agent`, `internal/config`,
|
||||
`internal/lockfile`, `internal/seed`, `internal/session`, `internal/shell`,
|
||||
`internal/workspace`)
|
||||
- vet: clean
|
||||
- artifacts:
|
||||
- `dist/ctask-linux-amd64` sha256 `808c71f982a3ed50f63bd5c4e1d25c4cf0643c887b8c2e011c5181a9020d1004` (5,288,098 bytes)
|
||||
- `dist/ctask-windows-amd64.exe` sha256 `c8dee43d5ade90899020fb8b31a41230672057b74478aa78f91d6f509dd689e8` (5,511,168 bytes)
|
||||
- `dist/checksums-sha256.txt` (`sha256sum -c` passes)
|
||||
- `dist/release-manifest.json` (valid JSON; commit `0e8e4a5d7bc4320cd933008d5b6e505f2b3c5ec4`)
|
||||
- `--version` output on Windows host: `ctask v0.6.1-rc.1 (0e8e4a5)`
|
||||
- Linux binary cross-exec'd on VPS (`netcup`): `ctask v0.6.1-rc.1 (0e8e4a5)` ✓
|
||||
|
||||
## Phase 3 — Gitea Actions workflow — **DRAFTED** (2026-05-20)
|
||||
|
||||
Added `.gitea/workflows/release.yml`. Not yet committed/pushed.
|
||||
|
||||
- Trigger: `push` of tags matching `v*`
|
||||
- `runs-on: ctask-release`
|
||||
- Tag parsed from `gitea.ref` (strips `refs/tags/`), NOT `gitea.ref_name`
|
||||
- Installs jq at job start (`golang:1.26-bookworm` base lacks it)
|
||||
- Steps: derive tag → `git clone --depth 1 --branch` (no `actions/checkout`)
|
||||
→ `go test ./...` + `go vet ./...` → `bash scripts/build-release.sh`
|
||||
→ create release via Gitea API (delete+recreate if RC and exists; refuse if
|
||||
non-RC and exists) → upload 4 assets → download-verify with `sha256sum -c`
|
||||
and `--version` substring match.
|
||||
- All JSON bodies built with `jq -nc` rather than string-interpolated to
|
||||
prevent quoting bugs.
|
||||
|
||||
## Still pending (token-dependent)
|
||||
|
||||
- Phase 0 token check: create+delete a draft release against the live repo.
|
||||
- Phase 1: create `typebasedio/ctask` (public), wire `origin` remote, push `main`.
|
||||
- Phase 3 secret: set `RELEASE_TOKEN` from the existing `workstation-dev-environment` token (no new PAT).
|
||||
- Phase 4: commit changes, push, tag `v0.6.1-rc.1`, validate the CI run.
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-release.sh — produce the ctask release artifact set under dist/.
|
||||
#
|
||||
# This is the single recipe shared between local validation and CI: the
|
||||
# Gitea Actions workflow invokes this same script. Running it locally
|
||||
# produces byte-identical artifacts (given the same Go toolchain version
|
||||
# and commit) so the build is locally verifiable before tagging.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/build-release.sh <version>
|
||||
#
|
||||
# <version> is the release tag verbatim, e.g. "v0.6.1-rc.1". It is
|
||||
# injected into the binary via -ldflags -X main.version. The current
|
||||
# git HEAD is injected as main.commit.
|
||||
#
|
||||
# Artifacts produced:
|
||||
# dist/ctask-linux-amd64
|
||||
# dist/ctask-windows-amd64.exe
|
||||
# dist/checksums-sha256.txt
|
||||
# dist/release-manifest.json
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?Usage: build-release.sh <version>}"
|
||||
COMMIT="$(git rev-parse HEAD)"
|
||||
|
||||
# Wipe dist/ to guarantee no stale artifacts from earlier builds leak
|
||||
# into the manifest or checksum file.
|
||||
rm -rf dist
|
||||
mkdir -p dist
|
||||
|
||||
# CGO_ENABLED=0 forces a pure-Go static link so the Linux binary runs in
|
||||
# minimal containers (alpine, distroless, scratch). -s -w strip symbols
|
||||
# and DWARF for a smaller artifact.
|
||||
LDFLAGS="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}"
|
||||
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o dist/ctask-linux-amd64 ./
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o dist/ctask-windows-amd64.exe ./
|
||||
|
||||
cd dist
|
||||
# Deterministic ordering so a diff between local and CI checksums is
|
||||
# easy to read.
|
||||
sha256sum ctask-linux-amd64 ctask-windows-amd64.exe > checksums-sha256.txt
|
||||
|
||||
LINUX_SHA=$(awk '/ctask-linux-amd64$/{print $1}' checksums-sha256.txt)
|
||||
WINDOWS_SHA=$(awk '/ctask-windows-amd64\.exe$/{print $1}' checksums-sha256.txt)
|
||||
|
||||
cat > release-manifest.json <<MANIFEST
|
||||
{
|
||||
"project": "ctask",
|
||||
"version": "${VERSION}",
|
||||
"commit": "${COMMIT}",
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "ctask-linux-amd64",
|
||||
"os": "linux",
|
||||
"arch": "amd64",
|
||||
"sha256": "${LINUX_SHA}"
|
||||
},
|
||||
{
|
||||
"name": "ctask-windows-amd64.exe",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"sha256": "${WINDOWS_SHA}"
|
||||
}
|
||||
]
|
||||
}
|
||||
MANIFEST
|
||||
|
||||
echo "Build complete:"
|
||||
cat checksums-sha256.txt
|
||||
echo ""
|
||||
cat release-manifest.json
|
||||
echo ""
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# release-check.sh — local mirror of the CI release pipeline.
|
||||
#
|
||||
# Runs the same gates the Gitea Actions workflow runs (tests, vet, build)
|
||||
# so a release is locally validated before any tag is pushed. The final
|
||||
# step prints the binary's --version so the operator can confirm the
|
||||
# build-time-injected version string matches the tag.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/release-check.sh <version>
|
||||
#
|
||||
# Example:
|
||||
# bash scripts/release-check.sh v0.6.1-rc.1
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?Usage: release-check.sh <version>}"
|
||||
|
||||
echo "=== Running tests ==="
|
||||
go test ./...
|
||||
|
||||
echo ""
|
||||
echo "=== Running go vet ==="
|
||||
go vet ./...
|
||||
|
||||
echo ""
|
||||
echo "=== Building release artifacts ==="
|
||||
bash "$(dirname "$0")/build-release.sh" "${VERSION}"
|
||||
|
||||
echo ""
|
||||
echo "=== Verifying version output ==="
|
||||
REPORTED="$(./dist/ctask-linux-amd64 --version 2>/dev/null || true)"
|
||||
if [ -z "${REPORTED}" ]; then
|
||||
# Linux binary won't run natively on Windows — fall back to the
|
||||
# Windows artifact so this script remains useful on the workstation.
|
||||
REPORTED="$(./dist/ctask-windows-amd64.exe --version)"
|
||||
echo "Binary reports (windows-amd64): ${REPORTED}"
|
||||
else
|
||||
echo "Binary reports (linux-amd64): ${REPORTED}"
|
||||
fi
|
||||
|
||||
# Soft-assert the version is present in the reported string. The CI
|
||||
# workflow's Verify-download step performs the same check.
|
||||
if ! echo "${REPORTED}" | grep -qF "${VERSION}"; then
|
||||
echo "ERROR: --version output does not contain ${VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "release-check OK for ${VERSION}"
|
||||
Reference in New Issue
Block a user